gabriel / muse public
for_each_ref.py python
227 lines 6.5 KB
96dd15b1 feat(plumbing): add symbolic-ref, for-each-ref, name-rev, check-ref-for… Gabriel Cardona <gabriel@tellurstori.com> 2d 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 and glob-pattern filtering so agent
5 pipelines can slice the ref list without post-processing.
6
7 Output (JSON, default)::
8
9 {
10 "refs": [
11 {
12 "ref": "refs/heads/dev",
13 "branch": "dev",
14 "commit_id": "<sha256>",
15 "author": "gabriel",
16 "message": "Add verse melody",
17 "committed_at": "2026-01-01T00:00:00+00:00",
18 "snapshot_id": "<sha256>"
19 }
20 ],
21 "count": 1
22 }
23
24 Text output (``--format text``)::
25
26 <sha256> refs/heads/dev 2026-01-01T00:00:00+00:00 gabriel
27
28 Plumbing contract
29 -----------------
30
31 - Exit 0: refs emitted (list may be empty).
32 - Exit 1: unknown ``--sort`` field; bad ``--format``.
33 - Exit 3: I/O error reading refs or commit records.
34 """
35
36 from __future__ import annotations
37
38 import fnmatch
39 import json
40 import logging
41 import pathlib
42 from typing import TypedDict
43
44 import typer
45
46 from muse.core.errors import ExitCode
47 from muse.core.repo import require_repo
48 from muse.core.store import get_head_commit_id, read_commit
49
50 logger = logging.getLogger(__name__)
51
52 app = typer.Typer()
53
54 _FORMAT_CHOICES = ("json", "text")
55 _SORT_FIELDS = ("ref", "branch", "commit_id", "author", "committed_at", "message")
56
57
58 class _RefDetail(TypedDict):
59 ref: str
60 branch: str
61 commit_id: str
62 author: str
63 message: str
64 committed_at: str
65 snapshot_id: str
66
67
68 class _ForEachRefResult(TypedDict):
69 refs: list[_RefDetail]
70 count: int
71
72
73 def _list_all_refs(root: pathlib.Path) -> list[tuple[str, str]]:
74 """Return sorted (branch_name, commit_id) pairs from .muse/refs/heads/."""
75 heads_dir = root / ".muse" / "refs" / "heads"
76 if not heads_dir.exists():
77 return []
78 pairs: list[tuple[str, str]] = []
79 for child in sorted(heads_dir.iterdir()):
80 if not child.is_file():
81 continue
82 commit_id = child.read_text(encoding="utf-8").strip()
83 if commit_id:
84 pairs.append((child.name, commit_id))
85 return pairs
86
87
88 @app.callback(invoke_without_command=True)
89 def for_each_ref(
90 ctx: typer.Context,
91 pattern: str = typer.Option(
92 "",
93 "--pattern",
94 "-p",
95 help="fnmatch glob filter applied to the full ref name "
96 "(e.g. 'refs/heads/feat/*').",
97 ),
98 sort_by: str = typer.Option(
99 "ref",
100 "--sort",
101 "-s",
102 help=f"Field to sort by. One of: {', '.join(_SORT_FIELDS)}.",
103 ),
104 descending: bool = typer.Option(
105 False,
106 "--desc",
107 "-d",
108 help="Reverse the sort order (descending).",
109 ),
110 count: int = typer.Option(
111 0,
112 "--count",
113 "-n",
114 help="Limit output to the first N refs after sorting (0 = unlimited).",
115 ),
116 fmt: str = typer.Option(
117 "json", "--format", "-f", help="Output format: json or text."
118 ),
119 ) -> None:
120 """Iterate all branch refs with full commit metadata.
121
122 Emits each branch ref together with the commit it points to, including
123 the author, message, timestamp, and snapshot ID. Supports sorting by any
124 commit field and limiting the output count.
125
126 Useful for scripts that need to process all branches in timestamp order,
127 find the most recently updated branch, or filter branches by pattern.
128
129 Example — find the three most recently committed branches::
130
131 muse plumbing for-each-ref --sort committed_at --desc --count 3
132 """
133 if fmt not in _FORMAT_CHOICES:
134 typer.echo(
135 json.dumps(
136 {"error": f"Unknown format {fmt!r}. Valid: {', '.join(_FORMAT_CHOICES)}"}
137 )
138 )
139 raise typer.Exit(code=ExitCode.USER_ERROR)
140
141 if sort_by not in _SORT_FIELDS:
142 typer.echo(
143 json.dumps(
144 {
145 "error": f"Unknown sort field {sort_by!r}. "
146 f"Valid: {', '.join(_SORT_FIELDS)}"
147 }
148 )
149 )
150 raise typer.Exit(code=ExitCode.USER_ERROR)
151
152 root = require_repo()
153
154 try:
155 pairs = _list_all_refs(root)
156 except OSError as exc:
157 logger.debug("for-each-ref I/O error listing refs: %s", exc)
158 typer.echo(json.dumps({"error": str(exc)}))
159 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
160
161 # Apply glob filter.
162 if pattern:
163 pairs = [(b, c) for b, c in pairs if fnmatch.fnmatch(f"refs/heads/{b}", pattern)]
164
165 # Build detailed ref list (read each commit once).
166 details: list[_RefDetail] = []
167 for branch, commit_id in pairs:
168 try:
169 record = read_commit(root, commit_id)
170 except Exception as exc:
171 logger.debug("for-each-ref: cannot read commit %s: %s", commit_id[:12], exc)
172 record = None
173
174 if record is None:
175 details.append(
176 _RefDetail(
177 ref=f"refs/heads/{branch}",
178 branch=branch,
179 commit_id=commit_id,
180 author="",
181 message="(commit record missing)",
182 committed_at="",
183 snapshot_id="",
184 )
185 )
186 else:
187 details.append(
188 _RefDetail(
189 ref=f"refs/heads/{branch}",
190 branch=branch,
191 commit_id=commit_id,
192 author=record.author,
193 message=record.message,
194 committed_at=record.committed_at.isoformat(),
195 snapshot_id=record.snapshot_id,
196 )
197 )
198
199 # Sort — explicit dispatcher avoids TypedDict literal-key constraint.
200 def _sort_key(d: _RefDetail) -> str:
201 if sort_by == "branch":
202 return d["branch"]
203 if sort_by == "commit_id":
204 return d["commit_id"]
205 if sort_by == "author":
206 return d["author"]
207 if sort_by == "committed_at":
208 return d["committed_at"]
209 if sort_by == "message":
210 return d["message"]
211 return d["ref"]
212
213 details.sort(key=_sort_key, reverse=descending)
214
215 # Limit.
216 if count > 0:
217 details = details[:count]
218
219 if fmt == "text":
220 for d in details:
221 typer.echo(
222 f"{d['commit_id']} {d['ref']} {d['committed_at']} {d['author']}"
223 )
224 return
225
226 result: _ForEachRefResult = {"refs": details, "count": len(details)}
227 typer.echo(json.dumps(result))