"""muse check-ref-format — validate branch and ref names. Tests one or more names against Muse's branch-naming rules and reports whether each is valid. The same validation applied by ``muse branch`` and ``muse update-ref`` is exposed here for scripting, so pipelines can pre-validate names before attempting to create branches. Rules enforced -------------- - 1–255 characters. - No C0 control characters (0x00–0x1F), space (0x20), or DEL (0x7F). - No backslash. - No Git-banned punctuation: ``~``, ``^``, ``:``, ``?``, ``*``, ``[``. - No leading or trailing dot. - No consecutive dots (``..``). - No leading or trailing forward slash. - No consecutive forward slashes (``//``). - No single-dot path component (``/./`` or ``feat/.``). - No component ending in ``.lock``. - No ``@{`` sequence (git reflog notation). - Not the bare string ``@``. These conventions ensure Muse branch names are safe for remote syncing. Output (JSON, default):: { "results": [ {"name": "feat/my-branch", "valid": true, "error": null}, {"name": "bad..name", "valid": false, "error": "..."} ], "all_valid": false, "valid_count": 1, "invalid_count": 1, "duration_ms": 0.000012, "exit_code": 1 } ``exit_code`` mirrors the process exit code: ``0`` when all names are valid, ``1`` when any are invalid. Agents can parse a single JSON payload instead of inspecting the process exit code separately. Text output (``--format text``):: ok feat/my-branch FAIL bad..name → Branch name 'bad..name' contains forbidden characters With ``--quiet``: no output; exits 0 if all names are valid, 1 otherwise. With ``--invalid-only``: results filtered to invalid names only; ``valid_count`` and ``invalid_count`` still reflect the full input batch:: { "results": [{"name": "bad..name", "valid": false, "error": "..."}], "all_valid": false, "valid_count": 2, "invalid_count": 1, ... } With ``--rules``: emit the validation ruleset as JSON and exit:: { "max_length": 255, "forbidden_chars": ["\\\\", "C0 controls (0x00-0x1F)", ...], "forbidden_patterns": ["leading dot", "trailing dot", ...], "notes": "Forward slashes are allowed as namespace separators (feat/x).", "duration_ms": 0.000005, "exit_code": 0 } Output contract --------------- - Exit 0: all supplied names are valid; or ``--rules`` was used. - Exit 1: one or more names are invalid; no names supplied; bad ``--format``; ``--invalid-only`` combined with ``--quiet`` or ``--rules``. - (No Exit 3 — this command is pure CPU, no I/O.) JSON fields present in every successful response ------------------------------------------------ ``duration_ms`` Wall-clock time in seconds. Pure CPU — always sub-millisecond for reasonable batches. ``exit_code`` ``0`` when all names are valid, ``1`` when any are invalid. Matches the process exit code exactly. Lets agents evaluate the batch result without inspecting ``all_valid`` or the process exit code separately. Agent use --------- Validate a name generated by a pipeline before creating the branch:: muse check-ref-format "$BRANCH_NAME" --json \\ | python3 -c "import sys,json; sys.exit(json.load(sys.stdin)['exit_code'])" Triage a large batch — show only the broken names:: muse check-ref-format --invalid-only --stdin < candidate_names.txt --json Batch-validate many candidate names piped from another command:: echo -e "feat/x\\nbad..name\\nmain" | muse check-ref-format --stdin --json Query the rules themselves (useful for name-generation agents):: muse check-ref-format --rules --json Quiet exit-code check in a shell script:: muse check-ref-format --quiet "$BRANCH_NAME" """ import argparse import json import logging import sys from typing import TypedDict from muse.core.errors import ExitCode from muse.core.validation import sanitize_display, validate_branch_name from muse.core.timing import start_timer from muse.core.envelope import EnvelopeJson, make_envelope logger = logging.getLogger(__name__) class _RulesDict(TypedDict): max_length: int forbidden_chars: list[str] forbidden_patterns: list[str] notes: str # Machine-readable ruleset — emitted by --rules. # Must stay in sync with _BRANCH_FORBIDDEN_RE in muse.core.validation. _RULES: _RulesDict = { "max_length": 255, "forbidden_chars": [ "\\", "C0 controls (0x00-0x1F)", "space (0x20)", "DEL (0x7F)", "~", "^", ":", "?", "*", "[", ], "forbidden_patterns": [ "leading dot", "trailing dot", "consecutive dots (..)", "consecutive slashes (//)", "leading slash", "trailing slash", "single-dot path component (/./)", "component ending in .lock", "@{ sequence (git reflog notation)", "bare @ (git HEAD shorthand)", ], "notes": "Forward slashes are allowed as namespace separators (e.g. feat/x).", } class _CheckResult(TypedDict): name: str valid: bool error: str | None class _RefFormatRulesJson(EnvelopeJson): """Wire shape for --rules --json output.""" max_length: int forbidden_chars: list[str] forbidden_patterns: list[str] notes: str class _CheckRefFormatJson(EnvelopeJson): """Wire shape for name-validation --json output.""" results: list[_CheckResult] all_valid: bool valid_count: int invalid_count: int def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the check-ref-format subcommand.""" parser = subparsers.add_parser( "check-ref-format", help="Validate branch/ref names against Muse naming rules.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "names", nargs="*", help=( "One or more branch or ref names to validate. " "Combine with ``--stdin`` to read additional names from standard input." ), ) parser.add_argument( "--stdin", action="store_true", dest="from_stdin", help=( "Read additional names from standard input (one per line). " "Blank lines and lines starting with '#' are ignored." ), ) parser.add_argument( "--rules", action="store_true", dest="show_rules", help=( "Emit the validation ruleset as structured data and exit. " "Useful for agents generating branch names programmatically." ), ) parser.add_argument( "--quiet", "-q", action="store_true", help="No output. Exit 0 if all valid, exit 1 if any invalid.", ) parser.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.", ) parser.add_argument( "--invalid-only", action="store_true", dest="invalid_only", help=( "Show only invalid names in results. " "valid_count and invalid_count still reflect the full input batch. " "Useful for triaging large sets of generated branch names." ), ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Validate branch or ref names against Muse naming rules. Applies the same rules used by ``muse branch`` and ``muse update-ref``. Pre-validate names in agent pipelines before attempting to create a branch so invalid names fail early rather than leaving the repo in a partial state. Use ``--rules`` to inspect the full ruleset without providing names. Agent quickstart ---------------- :: muse check-ref-format feat/my-thing --json muse check-ref-format --rules --json echo -e "feat/x\nbad..name\nmain" | muse check-ref-format --stdin --json JSON fields ----------- results List of per-name objects: ``name``, ``valid`` (bool), ``error`` (reason string or ``null`` when valid). all_valid ``true`` if every name passed validation. valid_count Number of valid names. invalid_count Number of invalid names. exit_code 0 = all valid; 1 = any invalid. Exit codes ---------- 0 All names are valid. 1 Any name is invalid, or conflicting flags provided. """ elapsed = start_timer() json_out: bool = args.json_out cli_names: list[str] = args.names from_stdin: bool = args.from_stdin show_rules: bool = args.show_rules quiet: bool = args.quiet invalid_only: bool = getattr(args, "invalid_only", False) if invalid_only and quiet: print( json.dumps({"error": "--invalid-only and --quiet are mutually exclusive."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if invalid_only and show_rules: print( json.dumps({"error": "--invalid-only and --rules are mutually exclusive."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # --rules: emit ruleset and exit immediately (no names required). if show_rules: if not json_out: print(f"max_length: {_RULES['max_length']}") print(f"forbidden_chars: {_RULES['forbidden_chars']!r}") print("forbidden_patterns:") for p in _RULES["forbidden_patterns"]: print(f" - {p}") print(f"notes: {_RULES['notes']}") else: print(json.dumps(_RefFormatRulesJson(**make_envelope(elapsed), **_RULES))) raise SystemExit(0) # Collect all names. all_names: list[str] = list(cli_names) if from_stdin: for raw in sys.stdin: line = raw.strip() if not line or line.startswith("#"): continue all_names.append(line) if not all_names: print( json.dumps({"error": "At least one name argument is required."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) results: list[_CheckResult] = [] for name in all_names: try: validate_branch_name(name) results.append(_CheckResult(name=name, valid=True, error=None)) except (ValueError, TypeError) as exc: results.append(_CheckResult(name=name, valid=False, error=str(exc))) all_valid = all(r["valid"] for r in results) valid_count = sum(1 for r in results if r["valid"]) invalid_count = len(results) - valid_count exit_code = 0 if all_valid else ExitCode.USER_ERROR if quiet: raise SystemExit(exit_code) # --invalid-only: filter displayed results to invalid names only. # valid_count / invalid_count still reflect the full input batch. displayed = [r for r in results if not r["valid"]] if invalid_only else results if not json_out: for r in displayed: if r["valid"]: print(f"ok {sanitize_display(r['name'])}") else: print( f"FAIL {sanitize_display(r['name'])} → " f"{sanitize_display(r['error'] or '')}" ) if not all_valid: raise SystemExit(ExitCode.USER_ERROR) return print(json.dumps(_CheckRefFormatJson( **make_envelope(elapsed, exit_code=exit_code), results=displayed, all_valid=all_valid, valid_count=valid_count, invalid_count=invalid_count, ))) if not all_valid: raise SystemExit(ExitCode.USER_ERROR)