gabriel / muse public
rev_parse.py python
270 lines 9.0 KB
Raw
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
1 """muse rev-parse — resolve a ref to a full commit ID.
2
3 Resolves a branch name, ``HEAD``, or an abbreviated SHA prefix to the full
4 64-character SHA-256 commit ID.
5
6 Output (JSON, default)::
7
8 {"ref": "main", "commit_id": "<sha256>", "duration_ms": 0.9, "exit_code": 0}
9
10 With ``--abbrev-ref``::
11
12 {"ref": "HEAD", "branch": "main", "duration_ms": 0.4, "exit_code": 0} # JSON
13 main # text
14
15 Output (--format text)::
16
17 <sha256>
18
19 Error output (all error paths, always JSON to stdout)::
20
21 {"ref": "...", "commit_id": null, "error": "not found",
22 "duration_ms": 0.3, "exit_code": 1}
23
24 Output contract
25 ---------------
26
27 - Exit 0: ref resolved successfully.
28 - Exit 1: ref not found, ambiguous, empty, or unknown --format value.
29 - All JSON (success and error) carries ``duration_ms`` and ``exit_code``.
30 - Errors always land on **stdout** as JSON — never on stderr — so agents
31 can parse failures without stderr redirection.
32
33 Agent use
34 ---------
35
36 Canonical "what branch am I on?" query::
37
38 muse rev-parse --abbrev-ref HEAD --json
39 # → {"ref": "HEAD", "branch": "main", "duration_ms": 0.4, "exit_code": 0}
40
41 Canonical "what is HEAD?" query::
42
43 muse rev-parse HEAD --format text
44 # → sha256:<64 hex chars>
45 """
46
47 import argparse
48 import json
49 import logging
50 import re
51 import sys
52
53 from muse.core.types import long_id
54 from muse.core.envelope import EnvelopeJson, make_envelope
55 from muse.core.errors import ExitCode
56 from muse.core.repo import require_repo
57 from muse.core.timing import start_timer
58 from muse.core.refs import (
59 get_head_commit_id,
60 read_current_branch,
61 )
62 from muse.core.commits import (
63 find_commits_by_prefix,
64 read_commit,
65 )
66
67 logger = logging.getLogger(__name__)
68
69 type _ErrorPayload = dict[str, str | int | float | None]
70
71 class _RevParseJson(EnvelopeJson, total=False):
72 """JSON output for normal ref resolution."""
73
74 ref: str
75 commit_id: str | None
76
77 class _RevParseAbbrevJson(EnvelopeJson):
78 """JSON output for --abbrev-ref mode."""
79
80 ref: str
81 branch: str
82
83 _SHA256_FULL_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
84 _SHA256_PREFIX_RE = re.compile(r"^sha256:[0-9a-f]{1,63}$")
85 _BARE_HEX_RE = re.compile(r"^[0-9a-f]+$", re.IGNORECASE)
86
87 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
88 """Register the rev-parse subcommand."""
89 parser = subparsers.add_parser(
90 "rev-parse",
91 help="Resolve branch/HEAD/SHA prefix → full commit_id.",
92 description=__doc__,
93 formatter_class=argparse.RawDescriptionHelpFormatter,
94 )
95 parser.add_argument(
96 "ref",
97 help="Ref to resolve: branch name, 'HEAD', or commit ID prefix.",
98 )
99 parser.add_argument(
100 "--abbrev-ref",
101 action="store_true",
102 dest="abbrev_ref",
103 help=(
104 "Resolve HEAD (or a commit_id) to the branch name rather than "
105 "the commit ID. Canonical agent query: "
106 "`muse rev-parse --abbrev-ref HEAD`."
107 ),
108 )
109 parser.add_argument(
110 "--json", "-j",
111 action="store_true",
112 dest="json_out",
113 help="Emit machine-readable JSON (default: plain text commit_id).",
114 )
115 parser.set_defaults(func=run)
116
117 def run(args: argparse.Namespace) -> None:
118 """Resolve a branch name, HEAD, or SHA prefix to a full commit ID.
119
120 Useful for canonicalising refs in scripts and agent pipelines before
121 passing them to other commands. With ``--abbrev-ref``: return the
122 symbolic branch name — the idiomatic "what branch am I on?" query.
123 Error responses always land on **stdout** as JSON, never on stderr.
124
125 Agent quickstart::
126
127 muse rev-parse HEAD --json
128 muse rev-parse main --json
129 muse rev-parse --abbrev-ref HEAD --json
130 muse rev-parse sha256:abc123 --json
131
132 JSON fields (normal mode)::
133
134 ref str Ref as passed by the caller
135 commit_id str|null Full sha256:<hex> commit ID
136
137 JSON fields (--abbrev-ref mode)::
138
139 ref str Ref as passed by the caller (usually "HEAD")
140 branch str Symbolic branch name
141
142 Exit codes::
143
144 0 Success.
145 1 Ref not found, ambiguous, empty, or unknown --format value.
146 """
147 json_out: bool = args.json_out
148 ref: str = args.ref
149 abbrev_ref: bool = args.abbrev_ref
150
151 elapsed = start_timer()
152
153 def _emit_error(payload: _ErrorPayload, code: int) -> None:
154 """Emit a structured JSON error to stdout and exit.
155
156 Always writes to stdout (never stderr) so agents can parse errors
157 without stderr redirection.
158 """
159 payload.update(make_envelope(elapsed, exit_code=code))
160 print(json.dumps(payload))
161 raise SystemExit(code)
162
163 if not ref:
164 _emit_error(
165 {"ref": ref, "commit_id": None, "error": "ref must not be empty"},
166 ExitCode.USER_ERROR,
167 )
168
169 root = require_repo()
170
171 # ── --abbrev-ref: resolve HEAD → branch name ─────────────────────────────
172 if abbrev_ref:
173 branch = read_current_branch(root)
174 if json_out:
175 print(json.dumps(_RevParseAbbrevJson(**make_envelope(elapsed), ref=ref, branch=branch)))
176 else:
177 print(branch)
178 return
179
180 # ── normal ref resolution ─────────────────────────────────────────────────
181 commit_id: str | None = None
182
183 if ref.upper() == "HEAD":
184 branch = read_current_branch(root)
185 commit_id = get_head_commit_id(root, branch)
186 if commit_id is None:
187 _emit_error(
188 {"ref": ref, "commit_id": None, "error": "HEAD has no commits"},
189 ExitCode.USER_ERROR,
190 )
191 else:
192 # @{N} / branch@{N} — check reflog before any other resolution so
193 # out-of-range produces a useful error rather than a silent "not found".
194 from muse.core.reflog import resolve_reflog_ref as _resolve_reflog_ref
195 try:
196 reflog_id = _resolve_reflog_ref(ref, root)
197 if reflog_id is not None:
198 commit_id = reflog_id
199 except FileNotFoundError:
200 _emit_error(
201 {"ref": ref, "commit_id": None, "error": "no reflog for this ref"},
202 ExitCode.USER_ERROR,
203 )
204 except IndexError as exc:
205 idx, total = exc.args
206 max_idx = total - 1
207 _emit_error(
208 {
209 "ref": ref,
210 "commit_id": None,
211 "error": f"@{{{idx}}} out of range; valid range: 0–{max_idx}",
212 },
213 ExitCode.USER_ERROR,
214 )
215
216 if commit_id is None:
217 # Try as branch name first. validate_branch_name (called inside
218 # get_head_commit_id) raises ValueError for refs containing control
219 # characters, ANSI escapes, or other forbidden sequences — treat
220 # these as "not found" rather than letting the exception escape.
221 try:
222 candidate = get_head_commit_id(root, ref)
223 except ValueError:
224 candidate = None
225 if candidate is not None:
226 commit_id = candidate
227 else:
228 # Try as a canonical content-addressed ID.
229 if _SHA256_FULL_RE.match(ref):
230 record = read_commit(root, ref)
231 if record is not None:
232 commit_id = record.commit_id
233 elif _SHA256_PREFIX_RE.match(ref):
234 bare_prefix = long_id(ref, strip=True)
235 matches = find_commits_by_prefix(root, bare_prefix)
236 if len(matches) == 1:
237 commit_id = matches[0].commit_id
238 elif len(matches) > 1:
239 _emit_error(
240 {
241 "ref": ref,
242 "commit_id": None,
243 "error": "ambiguous",
244 "candidates": [m.commit_id for m in matches],
245 },
246 ExitCode.USER_ERROR,
247 )
248 elif _BARE_HEX_RE.match(ref):
249 _emit_error(
250 {
251 "ref": ref,
252 "commit_id": None,
253 "error": (
254 f"bare hex ID not accepted; "
255 f"use the canonical 'sha256:{ref.lower()}' form"
256 ),
257 },
258 ExitCode.USER_ERROR,
259 )
260
261 if commit_id is None:
262 _emit_error(
263 {"ref": ref, "commit_id": None, "error": "not found"},
264 ExitCode.USER_ERROR,
265 )
266
267 if json_out:
268 print(json.dumps(_RevParseJson(**make_envelope(elapsed), ref=ref, commit_id=commit_id)))
269 else:
270 print(commit_id)
File History 3 commits
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago