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