"""muse 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. Bare hex prefixes (e.g. ``9f7c``) and ``sha256:``-prefixed short IDs (e.g. ``sha256:9f7c``) are both accepted. If a prefix matches more than one commit the result is marked ``ambiguous``. Output (JSON, default):: { "results": [ { "commit_id": "sha256:", "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 } ], "duration_ms": 1.2, "exit_code": 0 } JSON error schema (``--json`` mode):: { "status": "error", "error": "", "exit_code": } In ``--json`` mode all errors go to stdout as JSON — stderr will be empty. Agents should parse stdout and check ``exit_code``. Text output (``--format text``):: main~3 undefined With ``--name-only``:: main~3 undefined Output contract --------------- - ``exit_code`` 0: all names resolved (some may be ``undefined`` or ``ambiguous``). - ``exit_code`` 1: bad ``--format``; no commit IDs provided; non-hex input rejected. - ``exit_code`` 3: I/O error reading commit records. - ``elapsed()``: wall-clock milliseconds for the BFS walk and resolution. Agent use --------- Resolve HEAD commit to a name:: muse symbolic-ref HEAD --json \\ | python3 -c "import sys,json; print(json.load(sys.stdin)['commit_id'])" \\ | xargs muse name-rev --name-only --format text Resolve only relative to main (ignore other branches):: muse 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 name-rev --stdin --json Check if any of the last 5 commits are unreachable from any branch:: muse name-rev --stdin --json \\ | python3 -c "import sys,json; r=json.load(sys.stdin)['results']; print(any(x['undefined'] for x in r))" """ import argparse import fnmatch import json import logging import pathlib import sys from collections import deque from typing import TypedDict from muse.core.types import long_id, short_id from muse.core.errors import ExitCode from muse.core.refs import iter_branch_refs from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.repo import require_repo from muse.core.commits import read_commit from muse.core.validation import sanitize_display, validate_object_id from muse.core.timing import start_timer type _NameMap = dict[str, tuple[str, int]] logger = logging.getLogger(__name__) _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 class _NameRevJson(EnvelopeJson): """Stable JSON envelope for name-rev results.""" results: list[_NameRevEntry] class _NameRevErrorJson(EnvelopeJson): """Error payload for usage/internal errors in --json mode.""" status: str # "error" error: str def _emit_error(json_out: bool, msg: str, code: "ExitCode", elapsed: float) -> None: """Print an error and raise SystemExit. Never returns. In ``--json`` mode the error goes to stdout as a JSON payload so agents always get parseable output. In text mode it goes to stderr. """ if json_out: print(json.dumps(_NameRevErrorJson( **make_envelope(elapsed, exit_code=int(code)), status="error", error=msg, ))) else: print(f"❌ {msg}", file=sys.stderr) raise SystemExit(code) 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`. """ # (commit_id, branch_name, distance) queue: deque[tuple[str, str, int]] = deque() visited: _NameMap = {} for branch, tip_id in sorted(iter_branch_refs(root)): if branch_pattern is not None and not fnmatch.fnmatch(branch, branch_pattern): continue try: validate_object_id(tip_id) except ValueError: logger.debug( "name-rev: skipping ref %s — invalid commit ID %r", branch, short_id(tip_id), ) 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", short_id(cid), 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. Keys in *name_map* are always ``sha256:``-prefixed. Inputs may be bare hex (e.g. ``9f7c``) or ``sha256:``-prefixed (``sha256:9f7c``); both are normalised to ``sha256:``-prefixed before prefix matching so that bare short SHAs resolve correctly. """ # Exact match first (covers full sha256:-prefixed IDs). if cid_input in name_map: return cid_input, False # Normalise bare hex inputs to sha256:-prefixed for prefix matching. lookup = long_id(cid_input) if lookup in name_map: return lookup, False matches = [k for k in name_map if k.startswith(lookup)] 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", 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( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON instead of human text.", ) 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. Agent quickstart ---------------- :: muse name-rev sha256: --json muse name-rev sha256: sha256: --json muse name-rev sha256: --branch-pattern "feat/*" --json JSON fields ----------- results List of result objects per input: ``commit_id``, ``input``, ``name`` (``~N`` or ``null``), ``branch``, ``distance``, ``undefined`` (``true`` when no branch found), ``ambiguous``. Exit codes ---------- 0 Success (individual entries may have ``undefined: true``). 1 Invalid format or ``--max-walk`` < 1. 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = args.json_out 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 max_walk < 1: _emit_error(json_out, f"--max-walk must be >= 1, got {max_walk}", ExitCode.USER_ERROR, elapsed) # 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: _emit_error(json_out, "At least one commit ID is required.", ExitCode.USER_ERROR, elapsed) # Validate inputs: must be hex characters only (short or full), optionally # prefixed with "sha256:" in the canonical form. _HEX = frozenset("0123456789abcdefABCDEF") invalid: list[str] = [ s for s in all_inputs if not s or not all(c in _HEX for c in long_id(s, strip=True)) ] if invalid: _emit_error( json_out, ( f"Invalid commit ID(s): {invalid[:3]!r}. " "Commit IDs must be hex characters only, optionally prefixed with 'sha256:'." ), ExitCode.USER_ERROR, elapsed, ) 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) _emit_error(json_out, str(exc), ExitCode.INTERNAL_ERROR, elapsed) 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 not json_out: 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(_NameRevJson( **make_envelope(elapsed), results=[dict(r) for r in results], )))