gabriel / muse public
check_ref_format.py python
359 lines 11.5 KB
Raw
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