describe.py python
261 lines 8.3 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
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"]))
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago