gabriel / muse public
show_ref.py python
330 lines 9.6 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 10 days ago
1 """muse show-ref — list all refs known to this repository.
2
3 Enumerates every branch ref stored under ``.muse/refs/heads/`` and reports
4 the commit ID each one points to. Optionally filters by a glob pattern,
5 reports only the HEAD ref, verifies that a specific ref exists, or emits
6 only a count.
7
8 Output (JSON, default)::
9
10 {
11 "refs": [
12 {"ref": "refs/heads/dev", "commit_id": "<sha256>"},
13 {"ref": "refs/heads/main", "commit_id": "<sha256>"}
14 ],
15 "head": {
16 "ref": "refs/heads/main",
17 "branch": "main",
18 "commit_id": "<sha256>"
19 },
20 "count": 2,
21 "duration_ms": 1.3,
22 "exit_code": 0
23 }
24
25 Text output (``--format text``)::
26
27 <sha256> refs/heads/dev
28 * <sha256> refs/heads/main (HEAD)
29
30 Error output (``--json``, always to stdout so agents can parse failures)::
31
32 {
33 "error": "io_error",
34 "message": "<detail>",
35 "duration_ms": 0.4,
36 "exit_code": 3
37 }
38
39 Output contract
40 ---------------
41
42 - Exit 0: refs enumerated successfully (list may be empty).
43 - Exit 0: verify mode and ref exists.
44 - Exit 1: bad ``--format`` value; verify mode and ref does not exist.
45 - Exit 3: I/O error reading refs directory.
46 - All JSON responses carry ``duration_ms`` (wall-clock ms) and ``exit_code``
47 so agent pipelines can parse timing and success uniformly.
48
49 Agent use
50 ---------
51
52 Check whether a branch exists before starting work::
53
54 muse show-ref --verify refs/heads/feat/my-task --json
55 # → {"ref": "refs/heads/feat/my-task", "exists": true}
56
57 Count branches for capacity planning::
58
59 muse show-ref --count
60 # → {"count": 42}
61
62 Filter to a lane::
63
64 muse show-ref --pattern 'refs/heads/task/*' --json
65
66 Read HEAD without a round-trip to status::
67
68 muse show-ref --head --json
69 # → {"head": {"ref": "refs/heads/main", "branch": "main", "commit_id": "<sha256>"}}
70 """
71
72 import argparse
73 import fnmatch
74 import json
75 import logging
76 import pathlib
77 import sys
78 from typing import TypedDict
79
80 from muse.core.envelope import EnvelopeJson, make_envelope
81 from muse.core.paths import muse_dir as _muse_dir
82 from muse.core.errors import ExitCode
83 from muse.core.refs import iter_branch_refs
84 from muse.core.repo import require_repo
85 from muse.core.refs import (
86 get_head_commit_id,
87 read_current_branch,
88 )
89 from muse.core.validation import sanitize_display, validate_object_id
90 from muse.core.timing import start_timer
91
92 logger = logging.getLogger(__name__)
93
94 _SHA256_HEX_LEN = 64
95
96 class _RefEntry(TypedDict):
97 ref: str
98 commit_id: str
99
100 class _HeadInfo(TypedDict):
101 ref: str
102 branch: str
103 commit_id: str
104
105 class _ShowRefResult(EnvelopeJson):
106 refs: list[_RefEntry]
107 head: _HeadInfo | None
108 count: int
109
110 def _list_branch_refs(root: pathlib.Path) -> list[_RefEntry]:
111 """Return every branch ref under ``.muse/refs/heads/``, sorted by name.
112
113 Symlinks and empty ref files are skipped by :func:`iter_branch_refs`.
114 Ref files whose content is not a valid 64-char hex SHA-256 are also
115 skipped with a debug log — guards against corrupt or adversarially
116 crafted ref files injecting invalid commit IDs into consumer pipelines.
117 """
118 refs: list[_RefEntry] = []
119 for branch, commit_id in sorted(iter_branch_refs(root)):
120 try:
121 validate_object_id(commit_id)
122 except ValueError:
123 logger.debug(
124 "show-ref: skipping ref %r — invalid commit ID %r",
125 branch, commit_id,
126 )
127 continue
128 refs.append({"ref": f"refs/heads/{branch}", "commit_id": commit_id})
129 return refs
130
131 def _head_info(root: pathlib.Path) -> _HeadInfo | None:
132 """Return metadata about what HEAD currently points to, or ``None``."""
133 try:
134 muse_dir = _muse_dir(root)
135 branch = read_current_branch(root)
136 except Exception:
137 return None
138 commit_id = get_head_commit_id(root, branch)
139 if commit_id is None:
140 return None
141 return {
142 "ref": f"refs/heads/{branch}",
143 "branch": branch,
144 "commit_id": commit_id,
145 }
146
147 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
148 """Register the show-ref subcommand."""
149 parser = subparsers.add_parser(
150 "show-ref",
151 help="List all branch refs and their commit IDs.",
152 description=__doc__,
153 formatter_class=argparse.RawDescriptionHelpFormatter,
154 )
155 parser.add_argument(
156 "--pattern", "-p",
157 default=None,
158 dest="pattern",
159 metavar="GLOB",
160 help="fnmatch glob filter applied to the full ref name (e.g. 'refs/heads/feat/*').",
161 )
162 parser.add_argument(
163 "--head", "-H",
164 action="store_true",
165 dest="head_only",
166 help="Print only the HEAD ref and its commit ID.",
167 )
168 parser.add_argument(
169 "--verify", "-v",
170 default=None,
171 dest="verify_ref",
172 metavar="REF",
173 help=(
174 "Check whether the given ref exists. "
175 "Exits 0 if found, 1 if not. "
176 "With --json, emits {\"ref\": ..., \"exists\": bool} to stdout."
177 ),
178 )
179 parser.add_argument(
180 "--count",
181 action="store_true",
182 dest="count_only",
183 help=(
184 "Emit only the branch count. "
185 "With --json, emits {\"count\": N}. "
186 "With --pattern, counts matching refs. "
187 "Useful for capacity planning in agent pipelines."
188 ),
189 )
190 parser.add_argument(
191 "--json", "-j",
192 action="store_true",
193 dest="json_out",
194 help="Emit machine-readable JSON.",
195 )
196 parser.set_defaults(func=run)
197
198 def run(args: argparse.Namespace) -> None:
199 """List all refs known to this repository.
200
201 Reads every branch pointer from ``.muse/refs/heads/`` and reports their
202 commit IDs, sorted lexicographically. Ref files that are symlinks or
203 contain non-hex content are silently skipped to guard against corrupt
204 or adversarially crafted ref stores.
205
206 Agent quickstart::
207
208 muse show-ref --json
209 muse show-ref --verify refs/heads/feat/my-task --json
210 muse show-ref --pattern 'refs/heads/task/*' --json
211 muse show-ref --head --json
212
213 JSON fields::
214
215 refs list All refs: [{ref, commit_id}, ...]
216 head obj|null HEAD info: {ref, branch, commit_id}
217 count int Number of refs returned
218
219 Exit codes::
220
221 0 Success (empty list is valid).
222 1 Bad --format, or --verify ref not found.
223 3 I/O error reading refs directory.
224 """
225 json_out: bool = args.json_out
226 pattern: str | None = args.pattern
227 head_only: bool = args.head_only
228 verify_ref: str | None = args.verify_ref
229 count_only: bool = args.count_only
230
231 elapsed = start_timer()
232
233 def _emit_error(msg: str, code: int, error_key: str = "error") -> None:
234 """Emit a structured error to stdout (JSON) or stderr (text) then exit."""
235 if json_out:
236 print(json.dumps({
237 **make_envelope(elapsed, exit_code=code),
238 "error": error_key,
239 "message": msg,
240 }))
241 else:
242 print(f"❌ Error: {msg}", file=sys.stderr)
243 raise SystemExit(code)
244
245 root = require_repo()
246
247 # --verify mode: existence check.
248 if verify_ref is not None:
249 try:
250 all_refs = _list_branch_refs(root)
251 except Exception as exc:
252 logger.debug("show-ref I/O error: %s", exc)
253 _emit_error(str(exc), ExitCode.INTERNAL_ERROR, "io_error")
254 exists = any(r["ref"] == verify_ref for r in all_refs)
255 code = 0 if exists else ExitCode.USER_ERROR
256 if json_out:
257 print(json.dumps({
258 **make_envelope(elapsed, exit_code=code),
259 "ref": verify_ref,
260 "exists": exists,
261 }))
262 raise SystemExit(code)
263
264 # --head mode: only HEAD.
265 if head_only:
266 info = _head_info(root)
267 if info is None:
268 if not json_out:
269 print("(no HEAD commit)")
270 else:
271 print(json.dumps({
272 **make_envelope(elapsed),
273 "head": None,
274 }))
275 return
276
277 if not json_out:
278 print(
279 f"{sanitize_display(info['commit_id'])} "
280 f"{sanitize_display(info['ref'])} (HEAD)"
281 )
282 else:
283 print(json.dumps({
284 **make_envelope(elapsed),
285 "head": dict(info),
286 }))
287 return
288
289 # Collect refs, applying optional glob filter.
290 try:
291 refs = _list_branch_refs(root)
292 except Exception as exc:
293 logger.debug("show-ref I/O error: %s", exc)
294 _emit_error(str(exc), ExitCode.INTERNAL_ERROR, "io_error")
295
296 if pattern is not None:
297 refs = [r for r in refs if fnmatch.fnmatch(r["ref"], pattern)]
298
299 # --count mode: emit count only.
300 if count_only:
301 if not json_out:
302 print(str(len(refs)))
303 else:
304 print(json.dumps({
305 **make_envelope(elapsed),
306 "count": len(refs),
307 }))
308 return
309
310 head = _head_info(root)
311
312 if not json_out:
313 head_ref = head["ref"] if head else None
314 for r in refs:
315 is_head = r["ref"] == head_ref
316 marker = "* " if is_head else " "
317 suffix = " (HEAD)" if is_head else ""
318 print(
319 f"{sanitize_display(r['commit_id'])} "
320 f"{marker}{sanitize_display(r['ref'])}{suffix}"
321 )
322 return
323
324 result = _ShowRefResult(
325 **make_envelope(elapsed),
326 refs=refs,
327 head=head,
328 count=len(refs),
329 )
330 print(json.dumps(result))
File History 11 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 10 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 13 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 16 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 19 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 25 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 35 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 37 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 48 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 51 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 57 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 57 days ago