"""``muse describe`` — label a commit by its nearest tag and hop distance. Walks backward from a commit (default: HEAD) through the ancestry graph and finds the nearest tag. The output is ``~N`` where N is the number of hops from the tag to the commit. N=0 means the commit is exactly on the tag and the ``~0`` suffix is omitted (bare tag name). This is the porcelain equivalent of ``git describe`` — useful for generating human-readable release labels in CI, changelogs, and agent pipelines. Usage:: muse describe # describe HEAD muse describe --ref feat/audio # describe the tip of a branch muse describe --long # always show distance + SHA muse describe --match "v*" # only consider tags matching a glob muse describe --first-parent # follow main-line ancestry only muse describe --exact-match # exit 1 if not on a tag muse describe --abbrev 8 # 8-char short SHA muse describe --json # machine-readable output JSON output schema:: { "commit_id": "", "tag": "" | null, "distance": , "short_sha": "", "name": "", "exact": true | false, "repo_id": "", "branch": "" } Exit codes:: 0 — description produced successfully 1 — ref not found, no commits on branch, --require-tag with no tags, or --exact-match when not on a tag 2 — not a Muse repository Security model:: Tag names and the ``--ref`` value are passed through ``sanitize_display`` before being emitted to the terminal to prevent ANSI injection. The ``name`` field is sanitized in text mode only; JSON output is always raw so that callers can interpret the original value. """ from __future__ import annotations import argparse import json import logging import sys from typing import TypedDict from muse.core.describe import describe_commit from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref from muse.core.validation import sanitize_display logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # JSON wire format # --------------------------------------------------------------------------- class _DescribeJson(TypedDict): """JSON output for ``muse describe``.""" commit_id: str tag: str | None distance: int short_sha: str name: str exact: bool repo_id: str branch: str # --------------------------------------------------------------------------- # Command registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse describe`` subcommand.""" parser = subparsers.add_parser( "describe", help="Label a commit by its nearest tag and hop distance.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--ref", default=None, help="Commit ref (SHA, branch, tag) to describe (default: HEAD).", ) parser.add_argument( "--long", "-l", action="store_true", dest="long_format", help="Always show distance + SHA even when exactly on a tag.", ) parser.add_argument( "--require-tag", action="store_true", dest="require_tag", help="Exit 1 if no tags exist in the ancestry.", ) parser.add_argument( "--exact-match", action="store_true", dest="exact_match", help="Exit 1 if the commit is not exactly on a tag (distance > 0).", ) parser.add_argument( "--match", default=None, dest="match_pattern", metavar="GLOB", help="Only consider tags whose name matches GLOB (fnmatch syntax).", ) parser.add_argument( "--first-parent", action="store_true", dest="first_parent", help="Follow only the first-parent chain (skip merge second-parents).", ) parser.add_argument( "--abbrev", type=int, default=12, dest="abbrev", metavar="N", help="Length of the short SHA suffix (default: 12).", ) parser.add_argument( "--json", action="store_true", dest="output_json", help="Emit machine-readable JSON on stdout.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # Main handler # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> None: """Label a commit by its nearest tag and hop distance. Walks backward from the commit's ancestry until it finds the nearest matching tag. The result is ``~N`` for N hops, or just ```` when N=0. Falls back to the short SHA when no tag is reachable. With ``--json``:: { "commit_id": "abc123...", "tag": "v1.0.0", "distance": 3, "short_sha": "abc123456789", "name": "v1.0.0~3", "exact": false, "repo_id": "...", "branch": "main" } Examples:: muse describe # → v1.0.0~3 muse describe --ref v1.0.0 # → v1.0.0 (on the tag itself) muse describe --long # → v1.0.0-0-gabc123456789 muse describe --match "v*" # only semver tags muse describe --exact-match # exit 1 if not on a tag muse describe --first-parent # main-line ancestry only muse describe --abbrev 8 # → v1.0.0~3-gabcd1234 muse describe --json # machine-readable """ ref: str | None = args.ref long_format: bool = args.long_format require_tag: bool = args.require_tag exact_match: bool = args.exact_match match_pattern: str | None = args.match_pattern first_parent: bool = args.first_parent abbrev: int = args.abbrev output_json: bool = args.output_json if abbrev < 4 or abbrev > 64: print( f"❌ --abbrev must be between 4 and 64 (got {abbrev}).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) if ref is None: commit_id = get_head_commit_id(root, branch) if commit_id is None: print("❌ No commits on current branch.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) else: commit_rec = resolve_commit_ref(root, repo_id, branch, ref) if commit_rec is None: print( f"❌ Ref '{sanitize_display(ref)}' not found.", file=sys.stderr ) raise SystemExit(ExitCode.USER_ERROR) commit_id = commit_rec.commit_id result = describe_commit( root, repo_id, commit_id, long_format=long_format, match_pattern=match_pattern, first_parent=first_parent, abbrev=abbrev, exact_match=exact_match, ) if require_tag and result["tag"] is None: print( f"❌ No tags found in the ancestry of {commit_id[:abbrev]}.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if exact_match and not result["exact"]: print( f"❌ Commit {commit_id[:abbrev]} is not exactly on a tag " f"(nearest: {sanitize_display(result['tag'] or 'none')} " f"at distance {result['distance']}).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if output_json: payload = _DescribeJson( commit_id=result["commit_id"], tag=result["tag"], distance=result["distance"], short_sha=result["short_sha"], name=result["name"], exact=result["exact"], repo_id=repo_id, branch=branch, ) print(json.dumps(payload)) else: print(sanitize_display(result["name"]))