"""muse plumbing 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 plumbing 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 match ``git check-ref-format`` conventions so Muse branch names are safe to sync with Git-backed remotes. 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 } 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 ``--rules``: emit the validation ruleset as JSON and exit:: { "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", "bare @" ], "notes": "Forward slashes are allowed as namespace separators (feat/x)." } Plumbing 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``. - (No Exit 3 — this command is pure CPU, no I/O.) Agent use --------- Validate a name generated by a pipeline before creating the branch:: muse plumbing check-ref-format "$BRANCH_NAME" --json \\ | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d['all_valid'] else 1)" Batch-validate many candidate names piped from another command:: echo -e "feat/x\\nbad..name\\nmain" | muse plumbing check-ref-format --stdin --json Query the rules themselves (useful for name-generation agents):: muse plumbing check-ref-format --rules --json Quiet exit-code check in a shell script:: muse plumbing check-ref-format --quiet "$BRANCH_NAME" && git push origin HEAD """ from __future__ import annotations 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 logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") 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 _CheckRefFormatResult(TypedDict): 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( "--format", "-f", dest="fmt", default="json", metavar="FORMAT", help="Output format: json or text. (default: json)", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) 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 plumbing update-ref``. Use this in scripts to pre-validate names before attempting to create a branch, avoiding partial-failure states. The ``--rules`` flag emits the ruleset as structured data — useful for agents that need to generate valid names without trial-and-error. """ fmt: str = args.fmt cli_names: list[str] = args.names from_stdin: bool = args.from_stdin show_rules: bool = args.show_rules quiet: bool = args.quiet if fmt not in _FORMAT_CHOICES: print( json.dumps( {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} ), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # --rules: emit ruleset and exit immediately (no names required). if show_rules: if fmt == "text": 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(_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 if quiet: raise SystemExit(0 if all_valid else ExitCode.USER_ERROR) if fmt == "text": for r in results: 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 result: _CheckRefFormatResult = { "results": results, "all_valid": all_valid, "valid_count": valid_count, "invalid_count": invalid_count, } print(json.dumps(result)) if not all_valid: raise SystemExit(ExitCode.USER_ERROR)