gabriel / muse public
for_each_ref.py python
399 lines 12.0 KB
Raw
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 14 days ago
1 """muse for-each-ref — iterate all refs with rich commit metadata.
2
3 Enumerates every branch ref and emits the full commit metadata it points to.
4 Supports sorting by any commit field, glob-pattern filtering, and an optional
5 ``--no-commits`` fast-path so agent pipelines can slice the ref list without
6 loading every commit record.
7
8 Hierarchical branch names (e.g. ``feat/my-thing``, ``bugfix/PROJ-42``) are
9 fully supported — the command recursively walks ``.muse/refs/heads/``.
10
11 Output (JSON, default)::
12
13 {
14 "refs": [
15 {
16 "ref": "refs/heads/dev",
17 "branch": "dev",
18 "commit_id": "sha256:<64 hex>",
19 "author": "gabriel",
20 "message": "Add verse melody",
21 "committed_at": "2026-01-01T00:00:00+00:00",
22 "snapshot_id": "sha256:<64 hex>"
23 }
24 ],
25 "count": 1,
26 "current_branch": "dev",
27 "duration_ms": 0.004112,
28 "exit_code": 0
29 }
30
31 With ``--no-commits`` the ``author``, ``message``, ``committed_at``, and
32 ``snapshot_id`` fields are omitted::
33
34 {
35 "refs": [
36 {"ref": "refs/heads/dev", "branch": "dev", "commit_id": "sha256:<64 hex>"}
37 ],
38 "count": 1,
39 "current_branch": "dev",
40 "duration_ms": 0.000231,
41 "exit_code": 0
42 }
43
44 Text output (``--format text``)::
45
46 <sha256> refs/heads/dev 2026-01-01T00:00:00+00:00 gabriel
47
48 Text output with ``--no-commits``::
49
50 <sha256> refs/heads/dev
51
52 Output contract
53 ---------------
54
55 - Exit 0: refs emitted (list may be empty).
56 - Exit 1: unknown ``--sort`` field; bad ``--format``; negative ``--count``.
57 - Exit 3: I/O error reading refs or commit records.
58
59 Agent use
60 ---------
61
62 Cheapest full ref list (skip commit I/O)::
63
64 muse for-each-ref --no-commits --json
65
66 Latest commit on every feat/* branch (sorted newest first)::
67
68 muse for-each-ref --pattern 'refs/heads/feat/*' \\
69 --sort committed_at --desc --json
70
71 Count branches matching a pattern::
72
73 muse for-each-ref --pattern 'refs/heads/bugfix/*' --json \\
74 | python3 -c "import sys,json; print(json.load(sys.stdin)['count'])"
75
76 Get the tip commit of exactly N most-recently-committed branches::
77
78 muse for-each-ref --sort committed_at --desc --count 5 --json
79 """
80
81 import argparse
82 import fnmatch
83 import json
84 import logging
85 import pathlib
86 import sys
87 from typing import TypedDict
88
89 from muse.core.types import short_id
90 from muse.core.envelope import EnvelopeJson, make_envelope
91 from muse.core.errors import ExitCode
92 from muse.core.refs import iter_branch_refs
93 from muse.core.repo import require_repo
94 from muse.core.refs import read_current_branch
95 from muse.core.commits import read_commit
96 from muse.core.validation import sanitize_display, validate_object_id
97 from muse.core.timing import start_timer
98
99 logger = logging.getLogger(__name__)
100
101 _SORT_FIELDS = (
102 "ref",
103 "branch",
104 "commit_id",
105 "author",
106 "committed_at",
107 "message",
108 "snapshot_id",
109 )
110
111 class _RefDetail(TypedDict, total=False):
112 """One ref entry.
113
114 The ``author``, ``message``, ``committed_at``, and ``snapshot_id``
115 fields are omitted when ``--no-commits`` is used.
116 """
117
118 ref: str
119 branch: str
120 commit_id: str
121 author: str
122 message: str
123 committed_at: str
124 snapshot_id: str
125
126 class _ForEachRefResult(EnvelopeJson):
127 """JSON output for ``muse for-each-ref --json``.
128
129 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
130
131 Fields
132 ------
133 refs List of ref detail objects (one per branch).
134 count Total number of refs returned.
135 current_branch The currently checked-out branch name.
136 """
137
138 refs: list[_RefDetail]
139 count: int
140 current_branch: str
141
142 def _list_all_refs(root: pathlib.Path) -> list[tuple[str, str]]:
143 """Return sorted (branch_name, commit_id) pairs from ``.muse/refs/heads/``.
144
145 Delegates to :func:`iter_branch_refs` which handles symlink skipping and
146 hierarchical branch names (``feat/my-thing``, ``bugfix/PROJ-42``).
147 Ref files whose contents are not a valid 64-char hex SHA-256 are also
148 skipped with a debug log.
149 """
150 pairs: list[tuple[str, str]] = []
151 for branch, commit_id in sorted(iter_branch_refs(root)):
152 try:
153 validate_object_id(commit_id)
154 except ValueError:
155 logger.debug(
156 "for-each-ref: skipping ref %s — invalid commit ID %r",
157 branch,
158 short_id(commit_id),
159 )
160 continue
161 pairs.append((branch, commit_id))
162 return pairs
163
164 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
165 """Register the for-each-ref subcommand."""
166 parser = subparsers.add_parser(
167 "for-each-ref",
168 help="Iterate all refs with rich commit metadata.",
169 description=__doc__,
170 formatter_class=argparse.RawDescriptionHelpFormatter,
171 )
172 parser.add_argument(
173 "--pattern", "-p",
174 default=None,
175 dest="pattern",
176 metavar="GLOB",
177 help=(
178 "fnmatch glob filter applied to the full ref name "
179 "(e.g. 'refs/heads/feat/*'). Omit to include all refs."
180 ),
181 )
182 parser.add_argument(
183 "--sort", "-s",
184 default="ref",
185 dest="sort_by",
186 metavar="FIELD",
187 help=f"Field to sort by. One of: {', '.join(_SORT_FIELDS)}. (default: ref)",
188 )
189 parser.add_argument(
190 "--desc",
191 action="store_true",
192 dest="descending",
193 help="Reverse the sort order (descending).",
194 )
195 parser.add_argument(
196 "--count",
197 type=int,
198 default=0,
199 dest="count_limit",
200 metavar="N",
201 help="Limit output to the first N refs after sorting (0 = unlimited).",
202 )
203 parser.add_argument(
204 "--no-commits",
205 action="store_true",
206 dest="no_commits",
207 help=(
208 "Skip loading commit records. Emits only ``ref``, ``branch``, "
209 "and ``commit_id`` fields. Significantly faster on large repos "
210 "when full commit metadata is not needed."
211 ),
212 )
213 parser.add_argument(
214 "--json", "-j", action="store_true", dest="json_out",
215 help="Emit machine-readable JSON instead of human text.",
216 )
217 parser.set_defaults(func=run)
218
219 def run(args: argparse.Namespace) -> None:
220 """Iterate all branch refs with full commit metadata.
221
222 Emits each branch ref together with the commit it points to, including
223 the author, message, timestamp, and snapshot ID. Pass ``--no-commits``
224 to skip commit record loading for a fast bulk ref enumeration.
225
226 Agent quickstart
227 ----------------
228 ::
229
230 muse for-each-ref --format json
231 muse for-each-ref --pattern "feat/*" --format json
232 muse for-each-ref --sort committed_at --format json
233 muse for-each-ref --no-commits --format json
234
235 JSON fields
236 -----------
237 refs List of ref objects: ``name``, ``commit_id``,
238 ``author``, ``committed_at``, ``message``, ``snapshot_id``.
239 count Total number of refs returned.
240 current_branch Currently checked-out branch name.
241
242 Exit codes
243 ----------
244 0 Success.
245 1 Invalid format or sort field.
246 2 Not inside a Muse repository.
247 """
248 elapsed = start_timer()
249 json_out: bool = args.json_out
250 pattern: str | None = args.pattern
251 sort_by: str = args.sort_by
252 descending: bool = args.descending
253 count_limit: int = args.count_limit
254 no_commits: bool = args.no_commits
255
256 if sort_by not in _SORT_FIELDS:
257 print(
258 json.dumps({
259 "error": (
260 f"Unknown sort field {sort_by!r}. "
261 f"Valid: {', '.join(_SORT_FIELDS)}"
262 )
263 }),
264 file=sys.stderr,
265 )
266 raise SystemExit(ExitCode.USER_ERROR)
267
268 if count_limit < 0:
269 print(
270 json.dumps({"error": f"--count must be >= 0, got {count_limit}"}),
271 file=sys.stderr,
272 )
273 raise SystemExit(ExitCode.USER_ERROR)
274
275 # --no-commits + sorting by commit-only fields is contradictory.
276 _commit_only_fields = {"author", "message", "committed_at", "snapshot_id"}
277 if no_commits and sort_by in _commit_only_fields:
278 print(
279 json.dumps({
280 "error": (
281 f"Cannot sort by {sort_by!r} with --no-commits "
282 "(field is not loaded). Use a ref-level field: "
283 "ref, branch, commit_id."
284 )
285 }),
286 file=sys.stderr,
287 )
288 raise SystemExit(ExitCode.USER_ERROR)
289
290 root = require_repo()
291
292 try:
293 pairs = _list_all_refs(root)
294 except OSError as exc:
295 logger.debug("for-each-ref I/O error listing refs: %s", exc)
296 print(json.dumps({"error": str(exc)}), file=sys.stderr)
297 raise SystemExit(ExitCode.INTERNAL_ERROR)
298
299 # Apply glob filter.
300 if pattern is not None:
301 pairs = [
302 (b, c) for b, c in pairs if fnmatch.fnmatch(f"refs/heads/{b}", pattern)
303 ]
304
305 # Build detailed ref list.
306 details: list[_RefDetail] = []
307 for branch, commit_id in pairs:
308 if no_commits:
309 details.append(
310 _RefDetail(
311 ref=f"refs/heads/{branch}",
312 branch=branch,
313 commit_id=commit_id,
314 )
315 )
316 continue
317
318 record = None
319 try:
320 record = read_commit(root, commit_id)
321 except (OSError, ValueError, KeyError) as exc:
322 logger.debug(
323 "for-each-ref: cannot read commit %s: %s", short_id(commit_id), exc
324 )
325
326 if record is None:
327 details.append(
328 _RefDetail(
329 ref=f"refs/heads/{branch}",
330 branch=branch,
331 commit_id=commit_id,
332 author="",
333 message="(commit record missing)",
334 committed_at="",
335 snapshot_id="",
336 )
337 )
338 else:
339 details.append(
340 _RefDetail(
341 ref=f"refs/heads/{branch}",
342 branch=branch,
343 commit_id=commit_id,
344 author=record.author,
345 message=record.message,
346 committed_at=record.committed_at.isoformat(),
347 snapshot_id=record.snapshot_id,
348 )
349 )
350
351 # Sort — explicit dispatcher avoids TypedDict key constraint on subscript.
352 def _sort_key(d: _RefDetail) -> str:
353 if sort_by == "branch":
354 return d.get("branch", "")
355 if sort_by == "commit_id":
356 return d.get("commit_id", "")
357 if sort_by == "author":
358 return d.get("author", "")
359 if sort_by == "committed_at":
360 return d.get("committed_at", "")
361 if sort_by == "message":
362 return d.get("message", "")
363 if sort_by == "snapshot_id":
364 return d.get("snapshot_id", "")
365 return d.get("ref", "")
366
367 details.sort(key=_sort_key, reverse=descending)
368
369 # Limit.
370 if count_limit > 0:
371 details = details[:count_limit]
372
373 if not json_out:
374 for d in details:
375 if no_commits:
376 print(
377 f"{sanitize_display(d.get('commit_id', ''))} "
378 f"{sanitize_display(d.get('ref', ''))}"
379 )
380 else:
381 print(
382 f"{sanitize_display(d.get('commit_id', ''))} "
383 f"{sanitize_display(d.get('ref', ''))} "
384 f"{sanitize_display(d.get('committed_at', ''))} "
385 f"{sanitize_display(d.get('author', ''))}"
386 )
387 return
388
389 try:
390 current_branch = read_current_branch(root)
391 except Exception:
392 current_branch = ""
393
394 print(json.dumps(_ForEachRefResult(
395 **make_envelope(elapsed),
396 refs=details,
397 count=len(details),
398 current_branch=current_branch,
399 )))
File History 2 commits
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 14 days ago
sha256:2562dffa0a0822ac1bdea854f9b267843c6ce95b497a9dc5c55837c80a3ebd0a feat: domain_command_registry — Phase 1 of muse#74 (SCR_01-03) Sonnet 4.6 patch 14 days ago