name_rev.py python
438 lines 13.6 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse plumbing name-rev — map commit IDs to branch-relative names.
2
3 For each supplied commit ID, walks the commit DAG from all branch tips
4 simultaneously and finds the branch + distance that best describes it.
5 The result is expressed as ``<branch>~N`` where N is the number of parent
6 hops from that branch tip to the commit (0 means the commit IS the tip).
7
8 The multi-source BFS ensures O(total-commits) time regardless of the number
9 of branches or input commit IDs — every commit is visited at most once.
10 Hierarchical branch names (``feat/my-thing``, ``bugfix/PROJ-42``) are
11 fully supported.
12
13 Short SHA prefixes (≥ 4 hex characters) are resolved automatically. If a
14 prefix matches more than one commit the result is marked ``ambiguous``.
15
16 Output (JSON, default)::
17
18 {
19 "results": [
20 {
21 "commit_id": "<sha256-full>",
22 "input": "<as-supplied-by-caller>",
23 "name": "main~3",
24 "branch": "main",
25 "distance": 3,
26 "undefined": false,
27 "ambiguous": false
28 },
29 {
30 "commit_id": null,
31 "input": "deadbeef",
32 "name": null,
33 "branch": null,
34 "distance": null,
35 "undefined": true,
36 "ambiguous": false
37 }
38 ]
39 }
40
41 Text output (``--format text``)::
42
43 <sha256> main~3
44 <sha256> undefined
45
46 With ``--name-only``::
47
48 main~3
49 undefined
50
51 Plumbing contract
52 -----------------
53
54 - Exit 0: all names resolved (some may be ``undefined`` or ``ambiguous``).
55 - Exit 1: bad ``--format``; no commit IDs provided; non-hex input rejected.
56 - Exit 3: I/O error reading commit records.
57
58 Agent use
59 ---------
60
61 Resolve HEAD commit to a name::
62
63 muse plumbing symbolic-ref HEAD --json \\
64 | python3 -c "import sys,json; print(json.load(sys.stdin)['commit_id'])" \\
65 | xargs muse plumbing name-rev --name-only --format text
66
67 Resolve only relative to main (ignore other branches)::
68
69 muse plumbing name-rev <sha> --branches main --json
70
71 Resolve a batch of short SHAs piped from another command::
72
73 muse log --max 10 --json \\
74 | python3 -c "import sys,json; [print(c['commit_id'][:12]) for c in json.load(sys.stdin)]" \\
75 | muse plumbing name-rev --stdin --json
76
77 Check if any of the last 5 commits are unreachable from any branch::
78
79 muse plumbing name-rev --stdin --json \\
80 | python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(any(x['undefined'] for x in r))"
81 """
82
83 from __future__ import annotations
84
85 import argparse
86 import fnmatch
87 import json
88 import logging
89 import pathlib
90 import sys
91 from collections import deque
92 from typing import TypedDict
93
94 from muse.core.errors import ExitCode
95 from muse.core.repo import require_repo
96 from muse.core.store import read_commit
97 from muse.core.validation import sanitize_display, validate_object_id
98
99
100 type _NameMap = dict[str, tuple[str, int]]
101 logger = logging.getLogger(__name__)
102
103 _FORMAT_CHOICES = ("json", "text")
104 _MAX_WALK = 50_000 # Safety ceiling — prevents runaway on pathological graphs
105
106
107 class _NameRevEntry(TypedDict):
108 commit_id: str | None
109 input: str
110 name: str | None
111 branch: str | None
112 distance: int | None
113 undefined: bool
114 ambiguous: bool
115
116
117 def _build_name_map(
118 root: pathlib.Path,
119 targets: set[str],
120 branch_pattern: str | None = None,
121 max_walk: int = _MAX_WALK,
122 ) -> _NameMap:
123 """Return a map of commit_id → (branch, distance) for all reachable commits.
124
125 Multi-source BFS from every branch tip. Each commit is visited at most
126 once — whichever branch reaches it first (shortest distance) wins.
127 Stops early once all *targets* have been found or *max_walk* is reached.
128
129 Hierarchical branch names (``feat/x``) are discovered via ``rglob``.
130 Symlinks and refs with invalid commit IDs are skipped defensively.
131
132 Args:
133 root: Repository root path.
134 targets: Full commit IDs to find — BFS stops early when all
135 are resolved.
136 branch_pattern: Optional fnmatch glob applied to branch names
137 (not full ref paths) to restrict BFS seeds.
138 ``None`` seeds from all branches.
139 max_walk: Maximum BFS steps before stopping. Defaults to
140 :data:`_MAX_WALK`.
141 """
142 heads_dir = root / ".muse" / "refs" / "heads"
143 if not heads_dir.exists():
144 return {}
145
146 # (commit_id, branch_name, distance)
147 queue: deque[tuple[str, str, int]] = deque()
148 visited: _NameMap = {}
149
150 for ref_path in sorted(heads_dir.rglob("*")):
151 if ref_path.is_symlink():
152 logger.debug("name-rev: skipping symlink ref %s", ref_path)
153 continue
154 if not ref_path.is_file():
155 continue
156 branch = ref_path.relative_to(heads_dir).as_posix()
157 if branch_pattern is not None and not fnmatch.fnmatch(branch, branch_pattern):
158 continue
159 tip_id = ref_path.read_text(encoding="utf-8").strip()
160 try:
161 validate_object_id(tip_id)
162 except ValueError:
163 logger.debug(
164 "name-rev: skipping ref %s — invalid commit ID %r",
165 branch,
166 tip_id[:20],
167 )
168 continue
169 if tip_id not in visited:
170 visited[tip_id] = (branch, 0)
171 queue.append((tip_id, branch, 0))
172
173 found = set(targets) & set(visited)
174 steps = 0
175
176 while queue and steps < max_walk:
177 cid, branch, dist = queue.popleft()
178 steps += 1
179
180 if cid in targets:
181 found.add(cid)
182 if found >= targets:
183 break
184
185 try:
186 record = read_commit(root, cid)
187 except (OSError, ValueError, KeyError) as exc:
188 logger.debug("name-rev: cannot read commit %s: %s", cid[:12], exc)
189 continue
190
191 if record is None:
192 continue
193
194 for parent_id in (record.parent_commit_id, record.parent2_commit_id):
195 if parent_id and parent_id not in visited:
196 visited[parent_id] = (branch, dist + 1)
197 queue.append((parent_id, branch, dist + 1))
198
199 return visited
200
201
202 def _resolve_prefix(
203 cid_input: str,
204 name_map: _NameMap,
205 ) -> tuple[str | None, bool]:
206 """Resolve a (possibly short) commit ID against the BFS name map.
207
208 Returns ``(full_commit_id, ambiguous)`` where:
209 - ``full_commit_id`` is the resolved key in *name_map*, or ``None`` if
210 no match.
211 - ``ambiguous`` is ``True`` when the prefix matches more than one commit.
212
213 An exact match always wins over prefix matches.
214 """
215 if cid_input in name_map:
216 return cid_input, False
217 matches = [k for k in name_map if k.startswith(cid_input)]
218 if len(matches) == 1:
219 return matches[0], False
220 if len(matches) > 1:
221 return None, True
222 return None, False
223
224
225 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
226 """Register the name-rev subcommand."""
227 parser = subparsers.add_parser(
228 "name-rev",
229 help="Map commit IDs to descriptive branch-relative names.",
230 description=__doc__,
231 formatter_class=argparse.RawDescriptionHelpFormatter,
232 )
233 parser.add_argument(
234 "commit_ids",
235 nargs="*",
236 help=(
237 "One or more commit IDs (full or short prefix ≥ 4 chars) to map "
238 "to branch-relative names. Combine with ``--stdin`` to read "
239 "additional IDs from standard input."
240 ),
241 )
242 parser.add_argument(
243 "--stdin",
244 action="store_true",
245 dest="from_stdin",
246 help=(
247 "Read additional commit IDs from standard input (one per line). "
248 "Blank lines and lines starting with '#' are ignored."
249 ),
250 )
251 parser.add_argument(
252 "--branches",
253 default=None,
254 dest="branch_pattern",
255 metavar="GLOB",
256 help=(
257 "Restrict BFS seeds to branch names matching this fnmatch glob "
258 "(e.g. 'main', 'feat/*'). Commits unreachable from matching "
259 "branches will appear as ``undefined``."
260 ),
261 )
262 parser.add_argument(
263 "--max-walk",
264 type=int,
265 default=_MAX_WALK,
266 dest="max_walk",
267 metavar="N",
268 help=(
269 f"Maximum BFS steps before stopping (default: {_MAX_WALK:,}). "
270 "Reduce for large repos where a rough answer is acceptable."
271 ),
272 )
273 parser.add_argument(
274 "--name-only", "-n",
275 action="store_true",
276 dest="name_only",
277 help="Emit only the name (or the undefined placeholder), not the commit ID.",
278 )
279 parser.add_argument(
280 "--undefined", "-u",
281 default="undefined",
282 dest="undefined_name",
283 metavar="STRING",
284 help="String to emit when a commit cannot be named. (default: 'undefined')",
285 )
286 parser.add_argument(
287 "--format", "-f",
288 dest="fmt",
289 default="json",
290 metavar="FORMAT",
291 help="Output format: json or text. (default: json)",
292 )
293 parser.add_argument(
294 "--json", action="store_const", const="json", dest="fmt",
295 help="Shorthand for --format json.",
296 )
297 parser.set_defaults(func=run)
298
299
300 def run(args: argparse.Namespace) -> None:
301 """Map commit IDs to descriptive branch-relative names.
302
303 For each commit ID, finds the branch tip that is closest (fewest parent
304 hops) and returns a name of the form ``<branch>~N``. When N is 0 the
305 commit is the branch tip itself.
306
307 Short SHA prefixes (≥ 4 hex characters) are resolved automatically.
308 """
309 fmt: str = args.fmt
310 cli_ids: list[str] = args.commit_ids
311 from_stdin: bool = args.from_stdin
312 branch_pattern: str | None = args.branch_pattern
313 max_walk: int = args.max_walk
314 name_only: bool = args.name_only
315 undefined_name: str = args.undefined_name
316
317 if fmt not in _FORMAT_CHOICES:
318 print(
319 json.dumps(
320 {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}
321 ),
322 file=sys.stderr,
323 )
324 raise SystemExit(ExitCode.USER_ERROR)
325
326 if max_walk < 1:
327 print(
328 json.dumps({"error": f"--max-walk must be >= 1, got {max_walk}"}),
329 file=sys.stderr,
330 )
331 raise SystemExit(ExitCode.USER_ERROR)
332
333 # Collect all commit ID inputs.
334 all_inputs: list[str] = list(cli_ids)
335 if from_stdin:
336 for raw in sys.stdin:
337 line = raw.strip()
338 if not line or line.startswith("#"):
339 continue
340 all_inputs.append(line)
341
342 if not all_inputs:
343 print(
344 json.dumps({"error": "At least one commit ID is required."}),
345 file=sys.stderr,
346 )
347 raise SystemExit(ExitCode.USER_ERROR)
348
349 # Validate inputs: must be hex characters only (short or full).
350 _HEX = frozenset("0123456789abcdefABCDEF")
351 invalid: list[str] = [
352 s for s in all_inputs if not s or not all(c in _HEX for c in s)
353 ]
354 if invalid:
355 print(
356 json.dumps({
357 "error": (
358 f"Non-hex commit ID(s): {invalid[:3]!r}. "
359 "Commit IDs must be hexadecimal characters only."
360 )
361 }),
362 file=sys.stderr,
363 )
364 raise SystemExit(ExitCode.USER_ERROR)
365
366 root = require_repo()
367
368 # Build name map — BFS from all (filtered) branch tips.
369 try:
370 name_map = _build_name_map(
371 root,
372 set(all_inputs), # exact targets used for early-exit optimisation
373 branch_pattern=branch_pattern,
374 max_walk=max_walk,
375 )
376 except OSError as exc:
377 logger.debug("name-rev I/O error: %s", exc)
378 print(json.dumps({"error": str(exc)}), file=sys.stderr)
379 raise SystemExit(ExitCode.INTERNAL_ERROR)
380
381 results: list[_NameRevEntry] = []
382 for cid_input in all_inputs:
383 full_id, ambiguous = _resolve_prefix(cid_input, name_map)
384 if ambiguous:
385 results.append(
386 _NameRevEntry(
387 commit_id=None,
388 input=cid_input,
389 name=None,
390 branch=None,
391 distance=None,
392 undefined=False,
393 ambiguous=True,
394 )
395 )
396 elif full_id is not None:
397 branch, dist = name_map[full_id]
398 human_name = branch if dist == 0 else f"{branch}~{dist}"
399 results.append(
400 _NameRevEntry(
401 commit_id=full_id,
402 input=cid_input,
403 name=human_name,
404 branch=branch,
405 distance=dist,
406 undefined=False,
407 ambiguous=False,
408 )
409 )
410 else:
411 results.append(
412 _NameRevEntry(
413 commit_id=None,
414 input=cid_input,
415 name=None,
416 branch=None,
417 distance=None,
418 undefined=True,
419 ambiguous=False,
420 )
421 )
422
423 if fmt == "text":
424 for r in results:
425 if r["ambiguous"]:
426 display_name = "(ambiguous)"
427 elif r["name"] is not None:
428 display_name = r["name"]
429 else:
430 display_name = undefined_name
431 display_cid = r["commit_id"] or r["input"]
432 if name_only:
433 print(sanitize_display(display_name))
434 else:
435 print(f"{sanitize_display(display_cid)} {sanitize_display(display_name)}")
436 return
437
438 print(json.dumps({"results": [dict(r) for r in results]}))
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago