check_ref_format.py
python
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
8 days ago
| 1 | """muse check-ref-format — validate branch and ref names. |
| 2 | |
| 3 | Tests one or more names against Muse's branch-naming rules and reports |
| 4 | whether each is valid. The same validation applied by ``muse branch`` and |
| 5 | ``muse update-ref`` is exposed here for scripting, so pipelines can |
| 6 | pre-validate names before attempting to create branches. |
| 7 | |
| 8 | Rules enforced |
| 9 | -------------- |
| 10 | - 1–255 characters. |
| 11 | - No C0 control characters (0x00–0x1F), space (0x20), or DEL (0x7F). |
| 12 | - No backslash. |
| 13 | - No Git-banned punctuation: ``~``, ``^``, ``:``, ``?``, ``*``, ``[``. |
| 14 | - No leading or trailing dot. |
| 15 | - No consecutive dots (``..``). |
| 16 | - No leading or trailing forward slash. |
| 17 | - No consecutive forward slashes (``//``). |
| 18 | - No single-dot path component (``/./`` or ``feat/.``). |
| 19 | - No component ending in ``.lock``. |
| 20 | - No ``@{`` sequence (git reflog notation). |
| 21 | - Not the bare string ``@``. |
| 22 | |
| 23 | These conventions ensure Muse branch names are safe for remote syncing. |
| 24 | |
| 25 | Output (JSON, default):: |
| 26 | |
| 27 | { |
| 28 | "results": [ |
| 29 | {"name": "feat/my-branch", "valid": true, "error": null}, |
| 30 | {"name": "bad..name", "valid": false, "error": "..."} |
| 31 | ], |
| 32 | "all_valid": false, |
| 33 | "valid_count": 1, |
| 34 | "invalid_count": 1, |
| 35 | "duration_ms": 0.000012, |
| 36 | "exit_code": 1 |
| 37 | } |
| 38 | |
| 39 | ``exit_code`` mirrors the process exit code: ``0`` when all names are valid, |
| 40 | ``1`` when any are invalid. Agents can parse a single JSON payload instead |
| 41 | of inspecting the process exit code separately. |
| 42 | |
| 43 | Text output (``--format text``):: |
| 44 | |
| 45 | ok feat/my-branch |
| 46 | FAIL bad..name → Branch name 'bad..name' contains forbidden characters |
| 47 | |
| 48 | With ``--quiet``: no output; exits 0 if all names are valid, 1 otherwise. |
| 49 | |
| 50 | With ``--invalid-only``: results filtered to invalid names only; ``valid_count`` |
| 51 | and ``invalid_count`` still reflect the full input batch:: |
| 52 | |
| 53 | { |
| 54 | "results": [{"name": "bad..name", "valid": false, "error": "..."}], |
| 55 | "all_valid": false, |
| 56 | "valid_count": 2, |
| 57 | "invalid_count": 1, |
| 58 | ... |
| 59 | } |
| 60 | |
| 61 | With ``--rules``: emit the validation ruleset as JSON and exit:: |
| 62 | |
| 63 | { |
| 64 | "max_length": 255, |
| 65 | "forbidden_chars": ["\\\\", "C0 controls (0x00-0x1F)", ...], |
| 66 | "forbidden_patterns": ["leading dot", "trailing dot", ...], |
| 67 | "notes": "Forward slashes are allowed as namespace separators (feat/x).", |
| 68 | "duration_ms": 0.000005, |
| 69 | "exit_code": 0 |
| 70 | } |
| 71 | |
| 72 | Output contract |
| 73 | --------------- |
| 74 | |
| 75 | - Exit 0: all supplied names are valid; or ``--rules`` was used. |
| 76 | - Exit 1: one or more names are invalid; no names supplied; bad ``--format``; |
| 77 | ``--invalid-only`` combined with ``--quiet`` or ``--rules``. |
| 78 | - (No Exit 3 — this command is pure CPU, no I/O.) |
| 79 | |
| 80 | JSON fields present in every successful response |
| 81 | ------------------------------------------------ |
| 82 | |
| 83 | ``duration_ms`` |
| 84 | Wall-clock time in seconds. Pure CPU — always sub-millisecond for |
| 85 | reasonable batches. |
| 86 | ``exit_code`` |
| 87 | ``0`` when all names are valid, ``1`` when any are invalid. Matches the |
| 88 | process exit code exactly. Lets agents evaluate the batch result without |
| 89 | inspecting ``all_valid`` or the process exit code separately. |
| 90 | |
| 91 | Agent use |
| 92 | --------- |
| 93 | |
| 94 | Validate a name generated by a pipeline before creating the branch:: |
| 95 | |
| 96 | muse check-ref-format "$BRANCH_NAME" --json \\ |
| 97 | | python3 -c "import sys,json; sys.exit(json.load(sys.stdin)['exit_code'])" |
| 98 | |
| 99 | Triage a large batch — show only the broken names:: |
| 100 | |
| 101 | muse check-ref-format --invalid-only --stdin < candidate_names.txt --json |
| 102 | |
| 103 | Batch-validate many candidate names piped from another command:: |
| 104 | |
| 105 | echo -e "feat/x\\nbad..name\\nmain" | muse check-ref-format --stdin --json |
| 106 | |
| 107 | Query the rules themselves (useful for name-generation agents):: |
| 108 | |
| 109 | muse check-ref-format --rules --json |
| 110 | |
| 111 | Quiet exit-code check in a shell script:: |
| 112 | |
| 113 | muse check-ref-format --quiet "$BRANCH_NAME" |
| 114 | """ |
| 115 | |
| 116 | import argparse |
| 117 | import json |
| 118 | import logging |
| 119 | import sys |
| 120 | from typing import TypedDict |
| 121 | |
| 122 | from muse.core.errors import ExitCode |
| 123 | from muse.core.validation import sanitize_display, validate_branch_name |
| 124 | from muse.core.timing import start_timer |
| 125 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 126 | |
| 127 | logger = logging.getLogger(__name__) |
| 128 | |
| 129 | class _RulesDict(TypedDict): |
| 130 | max_length: int |
| 131 | forbidden_chars: list[str] |
| 132 | forbidden_patterns: list[str] |
| 133 | notes: str |
| 134 | |
| 135 | # Machine-readable ruleset — emitted by --rules. |
| 136 | # Must stay in sync with _BRANCH_FORBIDDEN_RE in muse.core.validation. |
| 137 | _RULES: _RulesDict = { |
| 138 | "max_length": 255, |
| 139 | "forbidden_chars": [ |
| 140 | "\\", |
| 141 | "C0 controls (0x00-0x1F)", |
| 142 | "space (0x20)", |
| 143 | "DEL (0x7F)", |
| 144 | "~", "^", ":", "?", "*", "[", |
| 145 | ], |
| 146 | "forbidden_patterns": [ |
| 147 | "leading dot", |
| 148 | "trailing dot", |
| 149 | "consecutive dots (..)", |
| 150 | "consecutive slashes (//)", |
| 151 | "leading slash", |
| 152 | "trailing slash", |
| 153 | "single-dot path component (/./)", |
| 154 | "component ending in .lock", |
| 155 | "@{ sequence (git reflog notation)", |
| 156 | "bare @ (git HEAD shorthand)", |
| 157 | ], |
| 158 | "notes": "Forward slashes are allowed as namespace separators (e.g. feat/x).", |
| 159 | } |
| 160 | |
| 161 | class _CheckResult(TypedDict): |
| 162 | name: str |
| 163 | valid: bool |
| 164 | error: str | None |
| 165 | |
| 166 | class _RefFormatRulesJson(EnvelopeJson): |
| 167 | """Wire shape for --rules --json output.""" |
| 168 | |
| 169 | max_length: int |
| 170 | forbidden_chars: list[str] |
| 171 | forbidden_patterns: list[str] |
| 172 | notes: str |
| 173 | |
| 174 | class _CheckRefFormatJson(EnvelopeJson): |
| 175 | """Wire shape for name-validation --json output.""" |
| 176 | |
| 177 | results: list[_CheckResult] |
| 178 | all_valid: bool |
| 179 | valid_count: int |
| 180 | invalid_count: int |
| 181 | |
| 182 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 183 | """Register the check-ref-format subcommand.""" |
| 184 | parser = subparsers.add_parser( |
| 185 | "check-ref-format", |
| 186 | help="Validate branch/ref names against Muse naming rules.", |
| 187 | description=__doc__, |
| 188 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 189 | ) |
| 190 | parser.add_argument( |
| 191 | "names", |
| 192 | nargs="*", |
| 193 | help=( |
| 194 | "One or more branch or ref names to validate. " |
| 195 | "Combine with ``--stdin`` to read additional names from standard input." |
| 196 | ), |
| 197 | ) |
| 198 | parser.add_argument( |
| 199 | "--stdin", |
| 200 | action="store_true", |
| 201 | dest="from_stdin", |
| 202 | help=( |
| 203 | "Read additional names from standard input (one per line). " |
| 204 | "Blank lines and lines starting with '#' are ignored." |
| 205 | ), |
| 206 | ) |
| 207 | parser.add_argument( |
| 208 | "--rules", |
| 209 | action="store_true", |
| 210 | dest="show_rules", |
| 211 | help=( |
| 212 | "Emit the validation ruleset as structured data and exit. " |
| 213 | "Useful for agents generating branch names programmatically." |
| 214 | ), |
| 215 | ) |
| 216 | parser.add_argument( |
| 217 | "--quiet", "-q", |
| 218 | action="store_true", |
| 219 | help="No output. Exit 0 if all valid, exit 1 if any invalid.", |
| 220 | ) |
| 221 | parser.add_argument( |
| 222 | "--json", "-j", action="store_true", dest="json_out", |
| 223 | help="Emit machine-readable JSON.", |
| 224 | ) |
| 225 | parser.add_argument( |
| 226 | "--invalid-only", |
| 227 | action="store_true", |
| 228 | dest="invalid_only", |
| 229 | help=( |
| 230 | "Show only invalid names in results. " |
| 231 | "valid_count and invalid_count still reflect the full input batch. " |
| 232 | "Useful for triaging large sets of generated branch names." |
| 233 | ), |
| 234 | ) |
| 235 | parser.set_defaults(func=run) |
| 236 | |
| 237 | def run(args: argparse.Namespace) -> None: |
| 238 | """Validate branch or ref names against Muse naming rules. |
| 239 | |
| 240 | Applies the same rules used by ``muse branch`` and ``muse update-ref``. |
| 241 | Pre-validate names in agent pipelines before attempting to create a branch |
| 242 | so invalid names fail early rather than leaving the repo in a partial state. |
| 243 | Use ``--rules`` to inspect the full ruleset without providing names. |
| 244 | |
| 245 | Agent quickstart |
| 246 | ---------------- |
| 247 | :: |
| 248 | |
| 249 | muse check-ref-format feat/my-thing --json |
| 250 | muse check-ref-format --rules --json |
| 251 | echo -e "feat/x\nbad..name\nmain" | muse check-ref-format --stdin --json |
| 252 | |
| 253 | JSON fields |
| 254 | ----------- |
| 255 | results List of per-name objects: ``name``, ``valid`` (bool), |
| 256 | ``error`` (reason string or ``null`` when valid). |
| 257 | all_valid ``true`` if every name passed validation. |
| 258 | valid_count Number of valid names. |
| 259 | invalid_count Number of invalid names. |
| 260 | exit_code 0 = all valid; 1 = any invalid. |
| 261 | |
| 262 | Exit codes |
| 263 | ---------- |
| 264 | 0 All names are valid. |
| 265 | 1 Any name is invalid, or conflicting flags provided. |
| 266 | """ |
| 267 | elapsed = start_timer() |
| 268 | json_out: bool = args.json_out |
| 269 | cli_names: list[str] = args.names |
| 270 | from_stdin: bool = args.from_stdin |
| 271 | show_rules: bool = args.show_rules |
| 272 | quiet: bool = args.quiet |
| 273 | invalid_only: bool = getattr(args, "invalid_only", False) |
| 274 | |
| 275 | if invalid_only and quiet: |
| 276 | print( |
| 277 | json.dumps({"error": "--invalid-only and --quiet are mutually exclusive."}), |
| 278 | file=sys.stderr, |
| 279 | ) |
| 280 | raise SystemExit(ExitCode.USER_ERROR) |
| 281 | |
| 282 | if invalid_only and show_rules: |
| 283 | print( |
| 284 | json.dumps({"error": "--invalid-only and --rules are mutually exclusive."}), |
| 285 | file=sys.stderr, |
| 286 | ) |
| 287 | raise SystemExit(ExitCode.USER_ERROR) |
| 288 | |
| 289 | # --rules: emit ruleset and exit immediately (no names required). |
| 290 | if show_rules: |
| 291 | if not json_out: |
| 292 | print(f"max_length: {_RULES['max_length']}") |
| 293 | print(f"forbidden_chars: {_RULES['forbidden_chars']!r}") |
| 294 | print("forbidden_patterns:") |
| 295 | for p in _RULES["forbidden_patterns"]: |
| 296 | print(f" - {p}") |
| 297 | print(f"notes: {_RULES['notes']}") |
| 298 | else: |
| 299 | print(json.dumps(_RefFormatRulesJson(**make_envelope(elapsed), **_RULES))) |
| 300 | raise SystemExit(0) |
| 301 | |
| 302 | # Collect all names. |
| 303 | all_names: list[str] = list(cli_names) |
| 304 | if from_stdin: |
| 305 | for raw in sys.stdin: |
| 306 | line = raw.strip() |
| 307 | if not line or line.startswith("#"): |
| 308 | continue |
| 309 | all_names.append(line) |
| 310 | |
| 311 | if not all_names: |
| 312 | print( |
| 313 | json.dumps({"error": "At least one name argument is required."}), |
| 314 | file=sys.stderr, |
| 315 | ) |
| 316 | raise SystemExit(ExitCode.USER_ERROR) |
| 317 | |
| 318 | results: list[_CheckResult] = [] |
| 319 | for name in all_names: |
| 320 | try: |
| 321 | validate_branch_name(name) |
| 322 | results.append(_CheckResult(name=name, valid=True, error=None)) |
| 323 | except (ValueError, TypeError) as exc: |
| 324 | results.append(_CheckResult(name=name, valid=False, error=str(exc))) |
| 325 | |
| 326 | all_valid = all(r["valid"] for r in results) |
| 327 | valid_count = sum(1 for r in results if r["valid"]) |
| 328 | invalid_count = len(results) - valid_count |
| 329 | exit_code = 0 if all_valid else ExitCode.USER_ERROR |
| 330 | |
| 331 | if quiet: |
| 332 | raise SystemExit(exit_code) |
| 333 | |
| 334 | # --invalid-only: filter displayed results to invalid names only. |
| 335 | # valid_count / invalid_count still reflect the full input batch. |
| 336 | displayed = [r for r in results if not r["valid"]] if invalid_only else results |
| 337 | |
| 338 | if not json_out: |
| 339 | for r in displayed: |
| 340 | if r["valid"]: |
| 341 | print(f"ok {sanitize_display(r['name'])}") |
| 342 | else: |
| 343 | print( |
| 344 | f"FAIL {sanitize_display(r['name'])} → " |
| 345 | f"{sanitize_display(r['error'] or '')}" |
| 346 | ) |
| 347 | if not all_valid: |
| 348 | raise SystemExit(ExitCode.USER_ERROR) |
| 349 | return |
| 350 | |
| 351 | print(json.dumps(_CheckRefFormatJson( |
| 352 | **make_envelope(elapsed, exit_code=exit_code), |
| 353 | results=displayed, |
| 354 | all_valid=all_valid, |
| 355 | valid_count=valid_count, |
| 356 | invalid_count=invalid_count, |
| 357 | ))) |
| 358 | if not all_valid: |
| 359 | raise SystemExit(ExitCode.USER_ERROR) |
File History
1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
8 days ago