"""muse plumbing name-rev — map commit IDs to branch-relative names. For each supplied commit ID, walks the commit DAG from all branch tips simultaneously and finds the branch + distance that best describes it. The result is expressed as ``~N`` where N is the number of parent hops from that branch tip to the commit (0 means the commit IS the tip). The multi-source BFS ensures O(total-commits) time regardless of the number of branches or input commit IDs — every commit is visited at most once. Hierarchical branch names (``feat/my-thing``, ``bugfix/PROJ-42``) are fully supported. Short SHA prefixes (≥ 4 hex characters) are resolved automatically. If a prefix matches more than one commit the result is marked ``ambiguous``. Output (JSON, default):: { "results": [ { "commit_id": "", "input": "", "name": "main~3", "branch": "main", "distance": 3, "undefined": false, "ambiguous": false }, { "commit_id": null, "input": "deadbeef", "name": null, "branch": null, "distance": null, "undefined": true, "ambiguous": false } ] } Text output (``--format text``):: main~3 undefined With ``--name-only``:: main~3 undefined Plumbing contract ----------------- - Exit 0: all names resolved (some may be ``undefined`` or ``ambiguous``). - Exit 1: bad ``--format``; no commit IDs provided; non-hex input rejected. - Exit 3: I/O error reading commit records. Agent use --------- Resolve HEAD commit to a name:: muse plumbing symbolic-ref HEAD --json \\ | python3 -c "import sys,json; print(json.load(sys.stdin)['commit_id'])" \\ | xargs muse plumbing name-rev --name-only --format text Resolve only relative to main (ignore other branches):: muse plumbing name-rev --branches main --json Resolve a batch of short SHAs piped from another command:: muse log --max 10 --json \\ | python3 -c "import sys,json; [print(c['commit_id'][:12]) for c in json.load(sys.stdin)]" \\ | muse plumbing name-rev --stdin --json Check if any of the last 5 commits are unreachable from any branch:: muse plumbing name-rev --stdin --json \\ | python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(any(x['undefined'] for x in r))" """ from __future__ import annotations import argparse import fnmatch import json import logging import pathlib import sys from collections import deque from typing import TypedDict from muse.core.errors import ExitCode from muse.core.repo import require_repo from muse.core.store import read_commit from muse.core.validation import sanitize_display, validate_object_id type _NameMap = dict[str, tuple[str, int]] logger = logging.getLogger(__name__) _FORMAT_CHOICES = ("json", "text") _MAX_WALK = 50_000 # Safety ceiling — prevents runaway on pathological graphs class _NameRevEntry(TypedDict): commit_id: str | None input: str name: str | None branch: str | None distance: int | None undefined: bool ambiguous: bool def _build_name_map( root: pathlib.Path, targets: set[str], branch_pattern: str | None = None, max_walk: int = _MAX_WALK, ) -> _NameMap: """Return a map of commit_id → (branch, distance) for all reachable commits. Multi-source BFS from every branch tip. Each commit is visited at most once — whichever branch reaches it first (shortest distance) wins. Stops early once all *targets* have been found or *max_walk* is reached. Hierarchical branch names (``feat/x``) are discovered via ``rglob``. Symlinks and refs with invalid commit IDs are skipped defensively. Args: root: Repository root path. targets: Full commit IDs to find — BFS stops early when all are resolved. branch_pattern: Optional fnmatch glob applied to branch names (not full ref paths) to restrict BFS seeds. ``None`` seeds from all branches. max_walk: Maximum BFS steps before stopping. Defaults to :data:`_MAX_WALK`. """ heads_dir = root / ".muse" / "refs" / "heads" if not heads_dir.exists(): return {} # (commit_id, branch_name, distance) queue: deque[tuple[str, str, int]] = deque() visited: _NameMap = {} for ref_path in sorted(heads_dir.rglob("*")): if ref_path.is_symlink(): logger.debug("name-rev: skipping symlink ref %s", ref_path) continue if not ref_path.is_file(): continue branch = ref_path.relative_to(heads_dir).as_posix() if branch_pattern is not None and not fnmatch.fnmatch(branch, branch_pattern): continue tip_id = ref_path.read_text(encoding="utf-8").strip() try: validate_object_id(tip_id) except ValueError: logger.debug( "name-rev: skipping ref %s — invalid commit ID %r", branch, tip_id[:20], ) continue if tip_id not in visited: visited[tip_id] = (branch, 0) queue.append((tip_id, branch, 0)) found = set(targets) & set(visited) steps = 0 while queue and steps < max_walk: cid, branch, dist = queue.popleft() steps += 1 if cid in targets: found.add(cid) if found >= targets: break try: record = read_commit(root, cid) except (OSError, ValueError, KeyError) as exc: logger.debug("name-rev: cannot read commit %s: %s", cid[:12], exc) continue if record is None: continue for parent_id in (record.parent_commit_id, record.parent2_commit_id): if parent_id and parent_id not in visited: visited[parent_id] = (branch, dist + 1) queue.append((parent_id, branch, dist + 1)) return visited def _resolve_prefix( cid_input: str, name_map: _NameMap, ) -> tuple[str | None, bool]: """Resolve a (possibly short) commit ID against the BFS name map. Returns ``(full_commit_id, ambiguous)`` where: - ``full_commit_id`` is the resolved key in *name_map*, or ``None`` if no match. - ``ambiguous`` is ``True`` when the prefix matches more than one commit. An exact match always wins over prefix matches. """ if cid_input in name_map: return cid_input, False matches = [k for k in name_map if k.startswith(cid_input)] if len(matches) == 1: return matches[0], False if len(matches) > 1: return None, True return None, False def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the name-rev subcommand.""" parser = subparsers.add_parser( "name-rev", help="Map commit IDs to descriptive branch-relative names.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "commit_ids", nargs="*", help=( "One or more commit IDs (full or short prefix ≥ 4 chars) to map " "to branch-relative names. Combine with ``--stdin`` to read " "additional IDs from standard input." ), ) parser.add_argument( "--stdin", action="store_true", dest="from_stdin", help=( "Read additional commit IDs from standard input (one per line). " "Blank lines and lines starting with '#' are ignored." ), ) parser.add_argument( "--branches", default=None, dest="branch_pattern", metavar="GLOB", help=( "Restrict BFS seeds to branch names matching this fnmatch glob " "(e.g. 'main', 'feat/*'). Commits unreachable from matching " "branches will appear as ``undefined``." ), ) parser.add_argument( "--max-walk", type=int, default=_MAX_WALK, dest="max_walk", metavar="N", help=( f"Maximum BFS steps before stopping (default: {_MAX_WALK:,}). " "Reduce for large repos where a rough answer is acceptable." ), ) parser.add_argument( "--name-only", "-n", action="store_true", dest="name_only", help="Emit only the name (or the undefined placeholder), not the commit ID.", ) parser.add_argument( "--undefined", "-u", default="undefined", dest="undefined_name", metavar="STRING", help="String to emit when a commit cannot be named. (default: 'undefined')", ) 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: """Map commit IDs to descriptive branch-relative names. For each commit ID, finds the branch tip that is closest (fewest parent hops) and returns a name of the form ``~N``. When N is 0 the commit is the branch tip itself. Short SHA prefixes (≥ 4 hex characters) are resolved automatically. """ fmt: str = args.fmt cli_ids: list[str] = args.commit_ids from_stdin: bool = args.from_stdin branch_pattern: str | None = args.branch_pattern max_walk: int = args.max_walk name_only: bool = args.name_only undefined_name: str = args.undefined_name 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) if max_walk < 1: print( json.dumps({"error": f"--max-walk must be >= 1, got {max_walk}"}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Collect all commit ID inputs. all_inputs: list[str] = list(cli_ids) if from_stdin: for raw in sys.stdin: line = raw.strip() if not line or line.startswith("#"): continue all_inputs.append(line) if not all_inputs: print( json.dumps({"error": "At least one commit ID is required."}), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Validate inputs: must be hex characters only (short or full). _HEX = frozenset("0123456789abcdefABCDEF") invalid: list[str] = [ s for s in all_inputs if not s or not all(c in _HEX for c in s) ] if invalid: print( json.dumps({ "error": ( f"Non-hex commit ID(s): {invalid[:3]!r}. " "Commit IDs must be hexadecimal characters only." ) }), file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() # Build name map — BFS from all (filtered) branch tips. try: name_map = _build_name_map( root, set(all_inputs), # exact targets used for early-exit optimisation branch_pattern=branch_pattern, max_walk=max_walk, ) except OSError as exc: logger.debug("name-rev I/O error: %s", exc) print(json.dumps({"error": str(exc)}), file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) results: list[_NameRevEntry] = [] for cid_input in all_inputs: full_id, ambiguous = _resolve_prefix(cid_input, name_map) if ambiguous: results.append( _NameRevEntry( commit_id=None, input=cid_input, name=None, branch=None, distance=None, undefined=False, ambiguous=True, ) ) elif full_id is not None: branch, dist = name_map[full_id] human_name = branch if dist == 0 else f"{branch}~{dist}" results.append( _NameRevEntry( commit_id=full_id, input=cid_input, name=human_name, branch=branch, distance=dist, undefined=False, ambiguous=False, ) ) else: results.append( _NameRevEntry( commit_id=None, input=cid_input, name=None, branch=None, distance=None, undefined=True, ambiguous=False, ) ) if fmt == "text": for r in results: if r["ambiguous"]: display_name = "(ambiguous)" elif r["name"] is not None: display_name = r["name"] else: display_name = undefined_name display_cid = r["commit_id"] or r["input"] if name_only: print(sanitize_display(display_name)) else: print(f"{sanitize_display(display_cid)} {sanitize_display(display_name)}") return print(json.dumps({"results": [dict(r) for r in results]}))