gabriel / muse public
for_each_ref.py python
379 lines 11.3 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """muse plumbing 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>",
19 "author": "gabriel",
20 "message": "Add verse melody",
21 "committed_at": "2026-01-01T00:00:00+00:00",
22 "snapshot_id": "<sha256>"
23 }
24 ],
25 "count": 1
26 }
27
28 With ``--no-commits`` the ``author``, ``message``, ``committed_at``, and
29 ``snapshot_id`` fields are omitted::
30
31 {
32 "refs": [
33 {"ref": "refs/heads/dev", "branch": "dev", "commit_id": "<sha256>"}
34 ],
35 "count": 1
36 }
37
38 Text output (``--format text``)::
39
40 <sha256> refs/heads/dev 2026-01-01T00:00:00+00:00 gabriel
41
42 Text output with ``--no-commits``::
43
44 <sha256> refs/heads/dev
45
46 Plumbing contract
47 -----------------
48
49 - Exit 0: refs emitted (list may be empty).
50 - Exit 1: unknown ``--sort`` field; bad ``--format``; negative ``--count``.
51 - Exit 3: I/O error reading refs or commit records.
52
53 Agent use
54 ---------
55
56 Cheapest full ref list (skip commit I/O)::
57
58 muse plumbing for-each-ref --no-commits --json
59
60 Latest commit on every feat/* branch (sorted newest first)::
61
62 muse plumbing for-each-ref --pattern 'refs/heads/feat/*' \\
63 --sort committed_at --desc --json
64
65 Count branches matching a pattern::
66
67 muse plumbing for-each-ref --pattern 'refs/heads/bugfix/*' --json \\
68 | python3 -c "import sys,json; print(json.load(sys.stdin)['count'])"
69
70 Get the tip commit of exactly N most-recently-committed branches::
71
72 muse plumbing for-each-ref --sort committed_at --desc --count 5 --json
73 """
74
75 from __future__ import annotations
76
77 import argparse
78 import fnmatch
79 import json
80 import logging
81 import pathlib
82 import sys
83 from typing import TypedDict
84
85 from muse.core.errors import ExitCode
86 from muse.core.repo import require_repo
87 from muse.core.store import read_commit
88 from muse.core.validation import sanitize_display, validate_object_id
89
90 logger = logging.getLogger(__name__)
91
92 _FORMAT_CHOICES = ("json", "text")
93 _SORT_FIELDS = (
94 "ref",
95 "branch",
96 "commit_id",
97 "author",
98 "committed_at",
99 "message",
100 "snapshot_id",
101 )
102
103
104 class _RefDetail(TypedDict, total=False):
105 """One ref entry.
106
107 The ``author``, ``message``, ``committed_at``, and ``snapshot_id``
108 fields are omitted when ``--no-commits`` is used.
109 """
110
111 ref: str
112 branch: str
113 commit_id: str
114 author: str
115 message: str
116 committed_at: str
117 snapshot_id: str
118
119
120 class _ForEachRefResult(TypedDict):
121 refs: list[_RefDetail]
122 count: int
123
124
125 def _list_all_refs(root: pathlib.Path) -> list[tuple[str, str]]:
126 """Return sorted (branch_name, commit_id) pairs from ``.muse/refs/heads/``.
127
128 Uses ``rglob("*")`` so hierarchical branch names (``feat/my-thing``,
129 ``bugfix/PROJ-42``) are discovered correctly. Symlinks are skipped to
130 prevent path-traversal attacks via crafted symlinks in the ref store.
131 Ref files whose contents are not a valid 64-char hex SHA-256 are also
132 skipped with a debug log.
133 """
134 heads_dir = root / ".muse" / "refs" / "heads"
135 if not heads_dir.exists():
136 return []
137 pairs: list[tuple[str, str]] = []
138 for child in sorted(heads_dir.rglob("*")):
139 if child.is_symlink():
140 logger.debug("for-each-ref: skipping symlink ref %s", child)
141 continue
142 if not child.is_file():
143 continue
144 branch = child.relative_to(heads_dir).as_posix()
145 commit_id = child.read_text(encoding="utf-8").strip()
146 try:
147 validate_object_id(commit_id)
148 except ValueError:
149 logger.debug(
150 "for-each-ref: skipping ref %s — invalid commit ID %r",
151 branch,
152 commit_id[:20],
153 )
154 continue
155 pairs.append((branch, commit_id))
156 return pairs
157
158
159 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
160 """Register the for-each-ref subcommand."""
161 parser = subparsers.add_parser(
162 "for-each-ref",
163 help="Iterate all refs with rich commit metadata.",
164 description=__doc__,
165 formatter_class=argparse.RawDescriptionHelpFormatter,
166 )
167 parser.add_argument(
168 "--pattern", "-p",
169 default=None,
170 dest="pattern",
171 metavar="GLOB",
172 help=(
173 "fnmatch glob filter applied to the full ref name "
174 "(e.g. 'refs/heads/feat/*'). Omit to include all refs."
175 ),
176 )
177 parser.add_argument(
178 "--sort", "-s",
179 default="ref",
180 dest="sort_by",
181 metavar="FIELD",
182 help=f"Field to sort by. One of: {', '.join(_SORT_FIELDS)}. (default: ref)",
183 )
184 parser.add_argument(
185 "--desc", "-d",
186 action="store_true",
187 dest="descending",
188 help="Reverse the sort order (descending).",
189 )
190 parser.add_argument(
191 "--count", "-n",
192 type=int,
193 default=0,
194 dest="count_limit",
195 metavar="N",
196 help="Limit output to the first N refs after sorting (0 = unlimited).",
197 )
198 parser.add_argument(
199 "--no-commits",
200 action="store_true",
201 dest="no_commits",
202 help=(
203 "Skip loading commit records. Emits only ``ref``, ``branch``, "
204 "and ``commit_id`` fields. Significantly faster on large repos "
205 "when full commit metadata is not needed."
206 ),
207 )
208 parser.add_argument(
209 "--format", "-f",
210 dest="fmt",
211 default="json",
212 metavar="FORMAT",
213 help="Output format: json or text. (default: json)",
214 )
215 parser.add_argument(
216 "--json", action="store_const", const="json", dest="fmt",
217 help="Shorthand for --format json.",
218 )
219 parser.set_defaults(func=run)
220
221
222 def run(args: argparse.Namespace) -> None:
223 """Iterate all branch refs with full commit metadata.
224
225 Emits each branch ref together with the commit it points to, including
226 the author, message, timestamp, and snapshot ID. Pass ``--no-commits``
227 to skip commit record loading for a fast bulk ref enumeration.
228 """
229 fmt: str = args.fmt
230 pattern: str | None = args.pattern
231 sort_by: str = args.sort_by
232 descending: bool = args.descending
233 count_limit: int = args.count_limit
234 no_commits: bool = args.no_commits
235
236 if fmt not in _FORMAT_CHOICES:
237 print(
238 json.dumps(
239 {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}
240 ),
241 file=sys.stderr,
242 )
243 raise SystemExit(ExitCode.USER_ERROR)
244
245 if sort_by not in _SORT_FIELDS:
246 print(
247 json.dumps({
248 "error": (
249 f"Unknown sort field {sort_by!r}. "
250 f"Valid: {', '.join(_SORT_FIELDS)}"
251 )
252 }),
253 file=sys.stderr,
254 )
255 raise SystemExit(ExitCode.USER_ERROR)
256
257 if count_limit < 0:
258 print(
259 json.dumps({"error": f"--count must be >= 0, got {count_limit}"}),
260 file=sys.stderr,
261 )
262 raise SystemExit(ExitCode.USER_ERROR)
263
264 # --no-commits + sorting by commit-only fields is contradictory.
265 _commit_only_fields = {"author", "message", "committed_at", "snapshot_id"}
266 if no_commits and sort_by in _commit_only_fields:
267 print(
268 json.dumps({
269 "error": (
270 f"Cannot sort by {sort_by!r} with --no-commits "
271 "(field is not loaded). Use a ref-level field: "
272 "ref, branch, commit_id."
273 )
274 }),
275 file=sys.stderr,
276 )
277 raise SystemExit(ExitCode.USER_ERROR)
278
279 root = require_repo()
280
281 try:
282 pairs = _list_all_refs(root)
283 except OSError as exc:
284 logger.debug("for-each-ref I/O error listing refs: %s", exc)
285 print(json.dumps({"error": str(exc)}), file=sys.stderr)
286 raise SystemExit(ExitCode.INTERNAL_ERROR)
287
288 # Apply glob filter.
289 if pattern is not None:
290 pairs = [
291 (b, c) for b, c in pairs if fnmatch.fnmatch(f"refs/heads/{b}", pattern)
292 ]
293
294 # Build detailed ref list.
295 details: list[_RefDetail] = []
296 for branch, commit_id in pairs:
297 if no_commits:
298 details.append(
299 _RefDetail(
300 ref=f"refs/heads/{branch}",
301 branch=branch,
302 commit_id=commit_id,
303 )
304 )
305 continue
306
307 record = None
308 try:
309 record = read_commit(root, commit_id)
310 except (OSError, ValueError, KeyError) as exc:
311 logger.debug(
312 "for-each-ref: cannot read commit %s: %s", commit_id[:12], exc
313 )
314
315 if record is None:
316 details.append(
317 _RefDetail(
318 ref=f"refs/heads/{branch}",
319 branch=branch,
320 commit_id=commit_id,
321 author="",
322 message="(commit record missing)",
323 committed_at="",
324 snapshot_id="",
325 )
326 )
327 else:
328 details.append(
329 _RefDetail(
330 ref=f"refs/heads/{branch}",
331 branch=branch,
332 commit_id=commit_id,
333 author=record.author,
334 message=record.message,
335 committed_at=record.committed_at.isoformat(),
336 snapshot_id=record.snapshot_id,
337 )
338 )
339
340 # Sort — explicit dispatcher avoids TypedDict key constraint on subscript.
341 def _sort_key(d: _RefDetail) -> str:
342 if sort_by == "branch":
343 return d.get("branch", "")
344 if sort_by == "commit_id":
345 return d.get("commit_id", "")
346 if sort_by == "author":
347 return d.get("author", "")
348 if sort_by == "committed_at":
349 return d.get("committed_at", "")
350 if sort_by == "message":
351 return d.get("message", "")
352 if sort_by == "snapshot_id":
353 return d.get("snapshot_id", "")
354 return d.get("ref", "")
355
356 details.sort(key=_sort_key, reverse=descending)
357
358 # Limit.
359 if count_limit > 0:
360 details = details[:count_limit]
361
362 if fmt == "text":
363 for d in details:
364 if no_commits:
365 print(
366 f"{sanitize_display(d.get('commit_id', ''))} "
367 f"{sanitize_display(d.get('ref', ''))}"
368 )
369 else:
370 print(
371 f"{sanitize_display(d.get('commit_id', ''))} "
372 f"{sanitize_display(d.get('ref', ''))} "
373 f"{sanitize_display(d.get('committed_at', ''))} "
374 f"{sanitize_display(d.get('author', ''))}"
375 )
376 return
377
378 result: _ForEachRefResult = {"refs": details, "count": len(details)}
379 print(json.dumps(result))
File History 3 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago