gabriel / muse public
for_each_ref.py python
398 lines 11.9 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 126 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.store import read_commit, read_current_branch
95 from muse.core.validation import sanitize_display, validate_object_id
96 from muse.core.timing import start_timer
97
98 logger = logging.getLogger(__name__)
99
100 _SORT_FIELDS = (
101 "ref",
102 "branch",
103 "commit_id",
104 "author",
105 "committed_at",
106 "message",
107 "snapshot_id",
108 )
109
110 class _RefDetail(TypedDict, total=False):
111 """One ref entry.
112
113 The ``author``, ``message``, ``committed_at``, and ``snapshot_id``
114 fields are omitted when ``--no-commits`` is used.
115 """
116
117 ref: str
118 branch: str
119 commit_id: str
120 author: str
121 message: str
122 committed_at: str
123 snapshot_id: str
124
125 class _ForEachRefResult(EnvelopeJson):
126 """JSON output for ``muse for-each-ref --json``.
127
128 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
129
130 Fields
131 ------
132 refs List of ref detail objects (one per branch).
133 count Total number of refs returned.
134 current_branch The currently checked-out branch name.
135 """
136
137 refs: list[_RefDetail]
138 count: int
139 current_branch: str
140
141 def _list_all_refs(root: pathlib.Path) -> list[tuple[str, str]]:
142 """Return sorted (branch_name, commit_id) pairs from ``.muse/refs/heads/``.
143
144 Delegates to :func:`iter_branch_refs` which handles symlink skipping and
145 hierarchical branch names (``feat/my-thing``, ``bugfix/PROJ-42``).
146 Ref files whose contents are not a valid 64-char hex SHA-256 are also
147 skipped with a debug log.
148 """
149 pairs: list[tuple[str, str]] = []
150 for branch, commit_id in sorted(iter_branch_refs(root)):
151 try:
152 validate_object_id(commit_id)
153 except ValueError:
154 logger.debug(
155 "for-each-ref: skipping ref %s — invalid commit ID %r",
156 branch,
157 short_id(commit_id),
158 )
159 continue
160 pairs.append((branch, commit_id))
161 return pairs
162
163 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
164 """Register the for-each-ref subcommand."""
165 parser = subparsers.add_parser(
166 "for-each-ref",
167 help="Iterate all refs with rich commit metadata.",
168 description=__doc__,
169 formatter_class=argparse.RawDescriptionHelpFormatter,
170 )
171 parser.add_argument(
172 "--pattern", "-p",
173 default=None,
174 dest="pattern",
175 metavar="GLOB",
176 help=(
177 "fnmatch glob filter applied to the full ref name "
178 "(e.g. 'refs/heads/feat/*'). Omit to include all refs."
179 ),
180 )
181 parser.add_argument(
182 "--sort", "-s",
183 default="ref",
184 dest="sort_by",
185 metavar="FIELD",
186 help=f"Field to sort by. One of: {', '.join(_SORT_FIELDS)}. (default: ref)",
187 )
188 parser.add_argument(
189 "--desc",
190 action="store_true",
191 dest="descending",
192 help="Reverse the sort order (descending).",
193 )
194 parser.add_argument(
195 "--count",
196 type=int,
197 default=0,
198 dest="count_limit",
199 metavar="N",
200 help="Limit output to the first N refs after sorting (0 = unlimited).",
201 )
202 parser.add_argument(
203 "--no-commits",
204 action="store_true",
205 dest="no_commits",
206 help=(
207 "Skip loading commit records. Emits only ``ref``, ``branch``, "
208 "and ``commit_id`` fields. Significantly faster on large repos "
209 "when full commit metadata is not needed."
210 ),
211 )
212 parser.add_argument(
213 "--json", "-j", action="store_true", dest="json_out",
214 help="Emit machine-readable JSON instead of human text.",
215 )
216 parser.set_defaults(func=run)
217
218 def run(args: argparse.Namespace) -> None:
219 """Iterate all branch refs with full commit metadata.
220
221 Emits each branch ref together with the commit it points to, including
222 the author, message, timestamp, and snapshot ID. Pass ``--no-commits``
223 to skip commit record loading for a fast bulk ref enumeration.
224
225 Agent quickstart
226 ----------------
227 ::
228
229 muse for-each-ref --format json
230 muse for-each-ref --pattern "feat/*" --format json
231 muse for-each-ref --sort committed_at --format json
232 muse for-each-ref --no-commits --format json
233
234 JSON fields
235 -----------
236 refs List of ref objects: ``name``, ``commit_id``,
237 ``author``, ``committed_at``, ``message``, ``snapshot_id``.
238 count Total number of refs returned.
239 current_branch Currently checked-out branch name.
240
241 Exit codes
242 ----------
243 0 Success.
244 1 Invalid format or sort field.
245 2 Not inside a Muse repository.
246 """
247 elapsed = start_timer()
248 json_out: bool = args.json_out
249 pattern: str | None = args.pattern
250 sort_by: str = args.sort_by
251 descending: bool = args.descending
252 count_limit: int = args.count_limit
253 no_commits: bool = args.no_commits
254
255 if sort_by not in _SORT_FIELDS:
256 print(
257 json.dumps({
258 "error": (
259 f"Unknown sort field {sort_by!r}. "
260 f"Valid: {', '.join(_SORT_FIELDS)}"
261 )
262 }),
263 file=sys.stderr,
264 )
265 raise SystemExit(ExitCode.USER_ERROR)
266
267 if count_limit < 0:
268 print(
269 json.dumps({"error": f"--count must be >= 0, got {count_limit}"}),
270 file=sys.stderr,
271 )
272 raise SystemExit(ExitCode.USER_ERROR)
273
274 # --no-commits + sorting by commit-only fields is contradictory.
275 _commit_only_fields = {"author", "message", "committed_at", "snapshot_id"}
276 if no_commits and sort_by in _commit_only_fields:
277 print(
278 json.dumps({
279 "error": (
280 f"Cannot sort by {sort_by!r} with --no-commits "
281 "(field is not loaded). Use a ref-level field: "
282 "ref, branch, commit_id."
283 )
284 }),
285 file=sys.stderr,
286 )
287 raise SystemExit(ExitCode.USER_ERROR)
288
289 root = require_repo()
290
291 try:
292 pairs = _list_all_refs(root)
293 except OSError as exc:
294 logger.debug("for-each-ref I/O error listing refs: %s", exc)
295 print(json.dumps({"error": str(exc)}), file=sys.stderr)
296 raise SystemExit(ExitCode.INTERNAL_ERROR)
297
298 # Apply glob filter.
299 if pattern is not None:
300 pairs = [
301 (b, c) for b, c in pairs if fnmatch.fnmatch(f"refs/heads/{b}", pattern)
302 ]
303
304 # Build detailed ref list.
305 details: list[_RefDetail] = []
306 for branch, commit_id in pairs:
307 if no_commits:
308 details.append(
309 _RefDetail(
310 ref=f"refs/heads/{branch}",
311 branch=branch,
312 commit_id=commit_id,
313 )
314 )
315 continue
316
317 record = None
318 try:
319 record = read_commit(root, commit_id)
320 except (OSError, ValueError, KeyError) as exc:
321 logger.debug(
322 "for-each-ref: cannot read commit %s: %s", short_id(commit_id), exc
323 )
324
325 if record is None:
326 details.append(
327 _RefDetail(
328 ref=f"refs/heads/{branch}",
329 branch=branch,
330 commit_id=commit_id,
331 author="",
332 message="(commit record missing)",
333 committed_at="",
334 snapshot_id="",
335 )
336 )
337 else:
338 details.append(
339 _RefDetail(
340 ref=f"refs/heads/{branch}",
341 branch=branch,
342 commit_id=commit_id,
343 author=record.author,
344 message=record.message,
345 committed_at=record.committed_at.isoformat(),
346 snapshot_id=record.snapshot_id,
347 )
348 )
349
350 # Sort — explicit dispatcher avoids TypedDict key constraint on subscript.
351 def _sort_key(d: _RefDetail) -> str:
352 if sort_by == "branch":
353 return d.get("branch", "")
354 if sort_by == "commit_id":
355 return d.get("commit_id", "")
356 if sort_by == "author":
357 return d.get("author", "")
358 if sort_by == "committed_at":
359 return d.get("committed_at", "")
360 if sort_by == "message":
361 return d.get("message", "")
362 if sort_by == "snapshot_id":
363 return d.get("snapshot_id", "")
364 return d.get("ref", "")
365
366 details.sort(key=_sort_key, reverse=descending)
367
368 # Limit.
369 if count_limit > 0:
370 details = details[:count_limit]
371
372 if not json_out:
373 for d in details:
374 if no_commits:
375 print(
376 f"{sanitize_display(d.get('commit_id', ''))} "
377 f"{sanitize_display(d.get('ref', ''))}"
378 )
379 else:
380 print(
381 f"{sanitize_display(d.get('commit_id', ''))} "
382 f"{sanitize_display(d.get('ref', ''))} "
383 f"{sanitize_display(d.get('committed_at', ''))} "
384 f"{sanitize_display(d.get('author', ''))}"
385 )
386 return
387
388 try:
389 current_branch = read_current_branch(root)
390 except Exception:
391 current_branch = ""
392
393 print(json.dumps(_ForEachRefResult(
394 **make_envelope(elapsed),
395 refs=details,
396 count=len(details),
397 current_branch=current_branch,
398 )))
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 126 days ago