describe.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """``muse describe`` β label a commit by its nearest tag and hop distance. |
| 2 | |
| 3 | Walks backward from a commit (default: HEAD) through the ancestry graph and |
| 4 | finds the nearest tag. The output is ``<tag>~N`` where N is the number of |
| 5 | hops from the tag to the commit. N=0 means the commit is exactly on the tag |
| 6 | and the ``~0`` suffix is omitted (bare tag name). |
| 7 | |
| 8 | This is the porcelain equivalent of ``git describe`` β useful for generating |
| 9 | human-readable release labels in CI, changelogs, and agent pipelines. |
| 10 | |
| 11 | Usage:: |
| 12 | |
| 13 | muse describe # describe HEAD |
| 14 | muse describe --ref feat/audio # describe the tip of a branch |
| 15 | muse describe --long # always show distance + SHA |
| 16 | muse describe --match "v*" # only consider tags matching a glob |
| 17 | muse describe --first-parent # follow main-line ancestry only |
| 18 | muse describe --exact-match # exit 1 if not on a tag |
| 19 | muse describe --abbrev 8 # 8-char short SHA |
| 20 | muse describe --json # machine-readable output |
| 21 | |
| 22 | JSON output schema:: |
| 23 | |
| 24 | { |
| 25 | "commit_id": "<full sha>", |
| 26 | "tag": "<nearest tag name>" | null, |
| 27 | "distance": <int>, |
| 28 | "short_sha": "<abbrev chars>", |
| 29 | "name": "<description string>", |
| 30 | "exact": true | false, |
| 31 | "repo_id": "<repo uuid>", |
| 32 | "branch": "<current branch>" |
| 33 | } |
| 34 | |
| 35 | Exit codes:: |
| 36 | |
| 37 | 0 β description produced successfully |
| 38 | 1 β ref not found, no commits on branch, --require-tag with no tags, |
| 39 | or --exact-match when not on a tag |
| 40 | 2 β not a Muse repository |
| 41 | |
| 42 | Security model:: |
| 43 | |
| 44 | Tag names and the ``--ref`` value are passed through ``sanitize_display`` |
| 45 | before being emitted to the terminal to prevent ANSI injection. The |
| 46 | ``name`` field is sanitized in text mode only; JSON output is always |
| 47 | raw so that callers can interpret the original value. |
| 48 | """ |
| 49 | |
| 50 | from __future__ import annotations |
| 51 | |
| 52 | import argparse |
| 53 | import json |
| 54 | import logging |
| 55 | import sys |
| 56 | from typing import TypedDict |
| 57 | |
| 58 | from muse.core.describe import describe_commit |
| 59 | from muse.core.errors import ExitCode |
| 60 | from muse.core.repo import read_repo_id, require_repo |
| 61 | from muse.core.store import get_head_commit_id, read_current_branch, resolve_commit_ref |
| 62 | from muse.core.validation import sanitize_display |
| 63 | |
| 64 | logger = logging.getLogger(__name__) |
| 65 | |
| 66 | |
| 67 | # --------------------------------------------------------------------------- |
| 68 | # JSON wire format |
| 69 | # --------------------------------------------------------------------------- |
| 70 | |
| 71 | |
| 72 | class _DescribeJson(TypedDict): |
| 73 | """JSON output for ``muse describe``.""" |
| 74 | |
| 75 | commit_id: str |
| 76 | tag: str | None |
| 77 | distance: int |
| 78 | short_sha: str |
| 79 | name: str |
| 80 | exact: bool |
| 81 | repo_id: str |
| 82 | branch: str |
| 83 | |
| 84 | |
| 85 | # --------------------------------------------------------------------------- |
| 86 | # Command registration |
| 87 | # --------------------------------------------------------------------------- |
| 88 | |
| 89 | |
| 90 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 91 | """Register the ``muse describe`` subcommand.""" |
| 92 | parser = subparsers.add_parser( |
| 93 | "describe", |
| 94 | help="Label a commit by its nearest tag and hop distance.", |
| 95 | description=__doc__, |
| 96 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 97 | ) |
| 98 | parser.add_argument( |
| 99 | "--ref", |
| 100 | default=None, |
| 101 | help="Commit ref (SHA, branch, tag) to describe (default: HEAD).", |
| 102 | ) |
| 103 | parser.add_argument( |
| 104 | "--long", "-l", |
| 105 | action="store_true", |
| 106 | dest="long_format", |
| 107 | help="Always show distance + SHA even when exactly on a tag.", |
| 108 | ) |
| 109 | parser.add_argument( |
| 110 | "--require-tag", |
| 111 | action="store_true", |
| 112 | dest="require_tag", |
| 113 | help="Exit 1 if no tags exist in the ancestry.", |
| 114 | ) |
| 115 | parser.add_argument( |
| 116 | "--exact-match", |
| 117 | action="store_true", |
| 118 | dest="exact_match", |
| 119 | help="Exit 1 if the commit is not exactly on a tag (distance > 0).", |
| 120 | ) |
| 121 | parser.add_argument( |
| 122 | "--match", |
| 123 | default=None, |
| 124 | dest="match_pattern", |
| 125 | metavar="GLOB", |
| 126 | help="Only consider tags whose name matches GLOB (fnmatch syntax).", |
| 127 | ) |
| 128 | parser.add_argument( |
| 129 | "--first-parent", |
| 130 | action="store_true", |
| 131 | dest="first_parent", |
| 132 | help="Follow only the first-parent chain (skip merge second-parents).", |
| 133 | ) |
| 134 | parser.add_argument( |
| 135 | "--abbrev", |
| 136 | type=int, |
| 137 | default=12, |
| 138 | dest="abbrev", |
| 139 | metavar="N", |
| 140 | help="Length of the short SHA suffix (default: 12).", |
| 141 | ) |
| 142 | parser.add_argument( |
| 143 | "--json", |
| 144 | action="store_true", |
| 145 | dest="output_json", |
| 146 | help="Emit machine-readable JSON on stdout.", |
| 147 | ) |
| 148 | parser.set_defaults(func=run) |
| 149 | |
| 150 | |
| 151 | # --------------------------------------------------------------------------- |
| 152 | # Main handler |
| 153 | # --------------------------------------------------------------------------- |
| 154 | |
| 155 | |
| 156 | def run(args: argparse.Namespace) -> None: |
| 157 | """Label a commit by its nearest tag and hop distance. |
| 158 | |
| 159 | Walks backward from the commit's ancestry until it finds the nearest |
| 160 | matching tag. The result is ``<tag>~N`` for N hops, or just ``<tag>`` |
| 161 | when N=0. Falls back to the short SHA when no tag is reachable. |
| 162 | |
| 163 | With ``--json``:: |
| 164 | |
| 165 | { |
| 166 | "commit_id": "abc123...", |
| 167 | "tag": "v1.0.0", |
| 168 | "distance": 3, |
| 169 | "short_sha": "abc123456789", |
| 170 | "name": "v1.0.0~3", |
| 171 | "exact": false, |
| 172 | "repo_id": "...", |
| 173 | "branch": "main" |
| 174 | } |
| 175 | |
| 176 | Examples:: |
| 177 | |
| 178 | muse describe # β v1.0.0~3 |
| 179 | muse describe --ref v1.0.0 # β v1.0.0 (on the tag itself) |
| 180 | muse describe --long # β v1.0.0-0-gabc123456789 |
| 181 | muse describe --match "v*" # only semver tags |
| 182 | muse describe --exact-match # exit 1 if not on a tag |
| 183 | muse describe --first-parent # main-line ancestry only |
| 184 | muse describe --abbrev 8 # β v1.0.0~3-gabcd1234 |
| 185 | muse describe --json # machine-readable |
| 186 | """ |
| 187 | ref: str | None = args.ref |
| 188 | long_format: bool = args.long_format |
| 189 | require_tag: bool = args.require_tag |
| 190 | exact_match: bool = args.exact_match |
| 191 | match_pattern: str | None = args.match_pattern |
| 192 | first_parent: bool = args.first_parent |
| 193 | abbrev: int = args.abbrev |
| 194 | output_json: bool = args.output_json |
| 195 | |
| 196 | if abbrev < 4 or abbrev > 64: |
| 197 | print( |
| 198 | f"β --abbrev must be between 4 and 64 (got {abbrev}).", |
| 199 | file=sys.stderr, |
| 200 | ) |
| 201 | raise SystemExit(ExitCode.USER_ERROR) |
| 202 | |
| 203 | root = require_repo() |
| 204 | repo_id = read_repo_id(root) |
| 205 | branch = read_current_branch(root) |
| 206 | |
| 207 | if ref is None: |
| 208 | commit_id = get_head_commit_id(root, branch) |
| 209 | if commit_id is None: |
| 210 | print("β No commits on current branch.", file=sys.stderr) |
| 211 | raise SystemExit(ExitCode.USER_ERROR) |
| 212 | else: |
| 213 | commit_rec = resolve_commit_ref(root, repo_id, branch, ref) |
| 214 | if commit_rec is None: |
| 215 | print( |
| 216 | f"β Ref '{sanitize_display(ref)}' not found.", file=sys.stderr |
| 217 | ) |
| 218 | raise SystemExit(ExitCode.USER_ERROR) |
| 219 | commit_id = commit_rec.commit_id |
| 220 | |
| 221 | result = describe_commit( |
| 222 | root, |
| 223 | repo_id, |
| 224 | commit_id, |
| 225 | long_format=long_format, |
| 226 | match_pattern=match_pattern, |
| 227 | first_parent=first_parent, |
| 228 | abbrev=abbrev, |
| 229 | exact_match=exact_match, |
| 230 | ) |
| 231 | |
| 232 | if require_tag and result["tag"] is None: |
| 233 | print( |
| 234 | f"β No tags found in the ancestry of {commit_id[:abbrev]}.", |
| 235 | file=sys.stderr, |
| 236 | ) |
| 237 | raise SystemExit(ExitCode.USER_ERROR) |
| 238 | |
| 239 | if exact_match and not result["exact"]: |
| 240 | print( |
| 241 | f"β Commit {commit_id[:abbrev]} is not exactly on a tag " |
| 242 | f"(nearest: {sanitize_display(result['tag'] or 'none')} " |
| 243 | f"at distance {result['distance']}).", |
| 244 | file=sys.stderr, |
| 245 | ) |
| 246 | raise SystemExit(ExitCode.USER_ERROR) |
| 247 | |
| 248 | if output_json: |
| 249 | payload = _DescribeJson( |
| 250 | commit_id=result["commit_id"], |
| 251 | tag=result["tag"], |
| 252 | distance=result["distance"], |
| 253 | short_sha=result["short_sha"], |
| 254 | name=result["name"], |
| 255 | exact=result["exact"], |
| 256 | repo_id=repo_id, |
| 257 | branch=branch, |
| 258 | ) |
| 259 | print(json.dumps(payload)) |
| 260 | else: |
| 261 | print(sanitize_display(result["name"])) |