check_ref_format.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
| 1 | """muse plumbing 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 plumbing 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 match ``git check-ref-format`` conventions so Muse branch names are safe |
| 24 | to sync with Git-backed remotes. |
| 25 | |
| 26 | Output (JSON, default):: |
| 27 | |
| 28 | { |
| 29 | "results": [ |
| 30 | {"name": "feat/my-branch", "valid": true, "error": null}, |
| 31 | {"name": "bad..name", "valid": false, "error": "..."} |
| 32 | ], |
| 33 | "all_valid": false, |
| 34 | "valid_count": 1, |
| 35 | "invalid_count": 1 |
| 36 | } |
| 37 | |
| 38 | Text output (``--format text``):: |
| 39 | |
| 40 | ok feat/my-branch |
| 41 | FAIL bad..name → Branch name 'bad..name' contains forbidden characters |
| 42 | |
| 43 | With ``--quiet``: no output; exits 0 if all names are valid, 1 otherwise. |
| 44 | |
| 45 | With ``--rules``: emit the validation ruleset as JSON and exit:: |
| 46 | |
| 47 | { |
| 48 | "max_length": 255, |
| 49 | "forbidden_chars": [ |
| 50 | "\\\\", "C0 controls (0x00-0x1F)", "space (0x20)", "DEL (0x7F)", |
| 51 | "~", "^", ":", "?", "*", "[" |
| 52 | ], |
| 53 | "forbidden_patterns": [ |
| 54 | "leading dot", |
| 55 | "trailing dot", |
| 56 | "consecutive dots (..)", |
| 57 | "consecutive slashes (//)", |
| 58 | "leading slash", |
| 59 | "trailing slash", |
| 60 | "single-dot path component (/./)", |
| 61 | "component ending in .lock", |
| 62 | "@{ sequence", |
| 63 | "bare @" |
| 64 | ], |
| 65 | "notes": "Forward slashes are allowed as namespace separators (feat/x)." |
| 66 | } |
| 67 | |
| 68 | Plumbing contract |
| 69 | ----------------- |
| 70 | |
| 71 | - Exit 0: all supplied names are valid; or ``--rules`` was used. |
| 72 | - Exit 1: one or more names are invalid; no names supplied; bad ``--format``. |
| 73 | - (No Exit 3 — this command is pure CPU, no I/O.) |
| 74 | |
| 75 | Agent use |
| 76 | --------- |
| 77 | |
| 78 | Validate a name generated by a pipeline before creating the branch:: |
| 79 | |
| 80 | muse plumbing check-ref-format "$BRANCH_NAME" --json \\ |
| 81 | | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d['all_valid'] else 1)" |
| 82 | |
| 83 | Batch-validate many candidate names piped from another command:: |
| 84 | |
| 85 | echo -e "feat/x\\nbad..name\\nmain" | muse plumbing check-ref-format --stdin --json |
| 86 | |
| 87 | Query the rules themselves (useful for name-generation agents):: |
| 88 | |
| 89 | muse plumbing check-ref-format --rules --json |
| 90 | |
| 91 | Quiet exit-code check in a shell script:: |
| 92 | |
| 93 | muse plumbing check-ref-format --quiet "$BRANCH_NAME" && git push origin HEAD |
| 94 | """ |
| 95 | |
| 96 | from __future__ import annotations |
| 97 | |
| 98 | import argparse |
| 99 | import json |
| 100 | import logging |
| 101 | import sys |
| 102 | from typing import TypedDict |
| 103 | |
| 104 | from muse.core.errors import ExitCode |
| 105 | from muse.core.validation import sanitize_display, validate_branch_name |
| 106 | |
| 107 | logger = logging.getLogger(__name__) |
| 108 | |
| 109 | _FORMAT_CHOICES = ("json", "text") |
| 110 | |
| 111 | |
| 112 | class _RulesDict(TypedDict): |
| 113 | max_length: int |
| 114 | forbidden_chars: list[str] |
| 115 | forbidden_patterns: list[str] |
| 116 | notes: str |
| 117 | |
| 118 | |
| 119 | # Machine-readable ruleset — emitted by --rules. |
| 120 | # Must stay in sync with _BRANCH_FORBIDDEN_RE in muse.core.validation. |
| 121 | _RULES: _RulesDict = { |
| 122 | "max_length": 255, |
| 123 | "forbidden_chars": [ |
| 124 | "\\", |
| 125 | "C0 controls (0x00-0x1F)", |
| 126 | "space (0x20)", |
| 127 | "DEL (0x7F)", |
| 128 | "~", "^", ":", "?", "*", "[", |
| 129 | ], |
| 130 | "forbidden_patterns": [ |
| 131 | "leading dot", |
| 132 | "trailing dot", |
| 133 | "consecutive dots (..)", |
| 134 | "consecutive slashes (//)", |
| 135 | "leading slash", |
| 136 | "trailing slash", |
| 137 | "single-dot path component (/./)", |
| 138 | "component ending in .lock", |
| 139 | "@{ sequence (git reflog notation)", |
| 140 | "bare @ (git HEAD shorthand)", |
| 141 | ], |
| 142 | "notes": "Forward slashes are allowed as namespace separators (e.g. feat/x).", |
| 143 | } |
| 144 | |
| 145 | |
| 146 | class _CheckResult(TypedDict): |
| 147 | name: str |
| 148 | valid: bool |
| 149 | error: str | None |
| 150 | |
| 151 | |
| 152 | class _CheckRefFormatResult(TypedDict): |
| 153 | results: list[_CheckResult] |
| 154 | all_valid: bool |
| 155 | valid_count: int |
| 156 | invalid_count: int |
| 157 | |
| 158 | |
| 159 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 160 | """Register the check-ref-format subcommand.""" |
| 161 | parser = subparsers.add_parser( |
| 162 | "check-ref-format", |
| 163 | help="Validate branch/ref names against Muse naming rules.", |
| 164 | description=__doc__, |
| 165 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 166 | ) |
| 167 | parser.add_argument( |
| 168 | "names", |
| 169 | nargs="*", |
| 170 | help=( |
| 171 | "One or more branch or ref names to validate. " |
| 172 | "Combine with ``--stdin`` to read additional names from standard input." |
| 173 | ), |
| 174 | ) |
| 175 | parser.add_argument( |
| 176 | "--stdin", |
| 177 | action="store_true", |
| 178 | dest="from_stdin", |
| 179 | help=( |
| 180 | "Read additional names from standard input (one per line). " |
| 181 | "Blank lines and lines starting with '#' are ignored." |
| 182 | ), |
| 183 | ) |
| 184 | parser.add_argument( |
| 185 | "--rules", |
| 186 | action="store_true", |
| 187 | dest="show_rules", |
| 188 | help=( |
| 189 | "Emit the validation ruleset as structured data and exit. " |
| 190 | "Useful for agents generating branch names programmatically." |
| 191 | ), |
| 192 | ) |
| 193 | parser.add_argument( |
| 194 | "--quiet", "-q", |
| 195 | action="store_true", |
| 196 | help="No output. Exit 0 if all valid, exit 1 if any invalid.", |
| 197 | ) |
| 198 | parser.add_argument( |
| 199 | "--format", "-f", |
| 200 | dest="fmt", |
| 201 | default="json", |
| 202 | metavar="FORMAT", |
| 203 | help="Output format: json or text. (default: json)", |
| 204 | ) |
| 205 | parser.add_argument( |
| 206 | "--json", action="store_const", const="json", dest="fmt", |
| 207 | help="Shorthand for --format json.", |
| 208 | ) |
| 209 | parser.set_defaults(func=run) |
| 210 | |
| 211 | |
| 212 | def run(args: argparse.Namespace) -> None: |
| 213 | """Validate branch or ref names against Muse naming rules. |
| 214 | |
| 215 | Applies the same rules used by ``muse branch`` and ``muse plumbing |
| 216 | update-ref``. Use this in scripts to pre-validate names before attempting |
| 217 | to create a branch, avoiding partial-failure states. |
| 218 | |
| 219 | The ``--rules`` flag emits the ruleset as structured data — useful for |
| 220 | agents that need to generate valid names without trial-and-error. |
| 221 | """ |
| 222 | fmt: str = args.fmt |
| 223 | cli_names: list[str] = args.names |
| 224 | from_stdin: bool = args.from_stdin |
| 225 | show_rules: bool = args.show_rules |
| 226 | quiet: bool = args.quiet |
| 227 | |
| 228 | if fmt not in _FORMAT_CHOICES: |
| 229 | print( |
| 230 | json.dumps( |
| 231 | {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"} |
| 232 | ), |
| 233 | file=sys.stderr, |
| 234 | ) |
| 235 | raise SystemExit(ExitCode.USER_ERROR) |
| 236 | |
| 237 | # --rules: emit ruleset and exit immediately (no names required). |
| 238 | if show_rules: |
| 239 | if fmt == "text": |
| 240 | print(f"max_length: {_RULES['max_length']}") |
| 241 | print(f"forbidden_chars: {_RULES['forbidden_chars']!r}") |
| 242 | print("forbidden_patterns:") |
| 243 | for p in _RULES["forbidden_patterns"]: |
| 244 | print(f" - {p}") |
| 245 | print(f"notes: {_RULES['notes']}") |
| 246 | else: |
| 247 | print(json.dumps(_RULES)) |
| 248 | raise SystemExit(0) |
| 249 | |
| 250 | # Collect all names. |
| 251 | all_names: list[str] = list(cli_names) |
| 252 | if from_stdin: |
| 253 | for raw in sys.stdin: |
| 254 | line = raw.strip() |
| 255 | if not line or line.startswith("#"): |
| 256 | continue |
| 257 | all_names.append(line) |
| 258 | |
| 259 | if not all_names: |
| 260 | print( |
| 261 | json.dumps({"error": "At least one name argument is required."}), |
| 262 | file=sys.stderr, |
| 263 | ) |
| 264 | raise SystemExit(ExitCode.USER_ERROR) |
| 265 | |
| 266 | results: list[_CheckResult] = [] |
| 267 | for name in all_names: |
| 268 | try: |
| 269 | validate_branch_name(name) |
| 270 | results.append(_CheckResult(name=name, valid=True, error=None)) |
| 271 | except (ValueError, TypeError) as exc: |
| 272 | results.append(_CheckResult(name=name, valid=False, error=str(exc))) |
| 273 | |
| 274 | all_valid = all(r["valid"] for r in results) |
| 275 | valid_count = sum(1 for r in results if r["valid"]) |
| 276 | invalid_count = len(results) - valid_count |
| 277 | |
| 278 | if quiet: |
| 279 | raise SystemExit(0 if all_valid else ExitCode.USER_ERROR) |
| 280 | |
| 281 | if fmt == "text": |
| 282 | for r in results: |
| 283 | if r["valid"]: |
| 284 | print(f"ok {sanitize_display(r['name'])}") |
| 285 | else: |
| 286 | print( |
| 287 | f"FAIL {sanitize_display(r['name'])} → " |
| 288 | f"{sanitize_display(r['error'] or '')}" |
| 289 | ) |
| 290 | if not all_valid: |
| 291 | raise SystemExit(ExitCode.USER_ERROR) |
| 292 | return |
| 293 | |
| 294 | result: _CheckRefFormatResult = { |
| 295 | "results": results, |
| 296 | "all_valid": all_valid, |
| 297 | "valid_count": valid_count, |
| 298 | "invalid_count": invalid_count, |
| 299 | } |
| 300 | print(json.dumps(result)) |
| 301 | if not all_valid: |
| 302 | raise SystemExit(ExitCode.USER_ERROR) |
File History
3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa
feat: Muse — version control for the agent era
Human
100 days ago