gabriel / muse public
reflog.py python
357 lines 12.6 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 28 days ago
1 """``muse reflog`` — inspect the history of HEAD and branch movements.
2
3 The reflog is a chronological journal of every time a ref moved: commits,
4 checkouts, merges, resets, cherry-picks, stash pops. It is your safety net
5 when you need to undo an operation that moved HEAD.
6
7 Usage::
8
9 muse reflog # HEAD reflog, last 20 entries
10 muse reflog --branch dev # dev branch reflog
11 muse reflog --limit 100 # show more entries
12 muse reflog --all # list all refs that have a reflog
13 muse reflog --operation commit # only commit events
14 muse reflog --author alice # only events by alice
15 muse reflog --since 2026-01-01 # entries on or after date
16 muse reflog --until 2026-03-01 # entries on or before date
17 muse reflog --json # machine-readable JSON
18
19 Each text row shows::
20
21 @{N} <new_sha12> (<old_sha12>) <when> <author> <operation>
22
23 The ``@{N}`` syntax mirrors Git so scripts that already understand Git
24 reflogs need no translation.
25
26 Security model
27 --------------
28 - Branch names are validated via ``validate_branch_name`` before being used
29 to construct a filesystem path — prevents path traversal.
30 - All user-controlled values (operation, author, commit IDs, branch names)
31 are passed through ``sanitize_display()`` before terminal output —
32 prevents ANSI injection from stored reflog data.
33 - Date filter values are validated as ``YYYY-MM-DD`` before use.
34 - Error messages go to **stderr**; **stdout** carries only data.
35
36 Agent UX
37 --------
38 Pass ``--json`` for a stable ``_ReflogResultJson`` object on stdout.
39 All fields are always present. Apply ``--operation``, ``--author``,
40 ``--since``, ``--until`` to narrow without changing the JSON schema.
41
42 JSON schema (``--json``)::
43
44 {
45 "ref": "refs/heads/main",
46 "total": 3,
47 "limit": 20,
48 "entries": [
49 {
50 "index": 0,
51 "new_id": "<64-char hex>",
52 "old_id": "<64-char hex or zeros>",
53 "timestamp": "2026-03-16T12:00:00+00:00",
54 "operation": "commit: add verse",
55 "author": "alice"
56 }
57 ]
58 }
59
60 Exit codes
61 ----------
62 - 0 — success
63 - 1 — invalid arguments (bad branch, bad format, bad date)
64 - 2 — not inside a Muse repository
65 """
66 from __future__ import annotations
67
68 import argparse
69 import datetime
70 import json
71 import logging
72 import sys
73 from typing import TYPE_CHECKING, TypedDict
74
75 from muse.core.errors import ExitCode
76 from muse.core.reflog import ReflogEntry, list_reflog_refs, read_reflog
77 from muse.core.repo import require_repo
78 from muse.core.validation import clamp_int, sanitize_display, validate_branch_name
79
80 if TYPE_CHECKING:
81 pass
82
83 logger = logging.getLogger(__name__)
84
85 # ---------------------------------------------------------------------------
86 # JSON TypedDicts — stable machine-readable output schemas
87 # ---------------------------------------------------------------------------
88
89
90 class _ReflogEntryJson(TypedDict):
91 """One entry in the JSON reflog output."""
92
93 index: int
94 new_id: str
95 old_id: str
96 timestamp: str
97 operation: str
98 author: str
99
100
101 class _ReflogResultJson(TypedDict):
102 """Top-level JSON object returned by ``muse reflog --json``."""
103
104 ref: str
105 total: int
106 limit: int
107 entries: list[_ReflogEntryJson]
108
109
110 class _ReflogAllJson(TypedDict):
111 """JSON object returned by ``muse reflog --all --json``."""
112
113 refs: list[str]
114 count: int
115
116
117 # ---------------------------------------------------------------------------
118 # Formatting helpers
119 # ---------------------------------------------------------------------------
120
121
122 def _fmt_entry(idx: int, entry: ReflogEntry, short: int = 12) -> str:
123 """Format one reflog entry for terminal display.
124
125 All user-controlled fields (new_id, old_id, author, operation) are
126 passed through ``sanitize_display()`` to prevent ANSI injection.
127 """
128 new_short = sanitize_display(entry.new_id[:short])
129 old_short = (
130 "initial"
131 if entry.old_id == "0" * 64
132 else sanitize_display(entry.old_id[:short])
133 )
134 when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
135 safe_op = sanitize_display(entry.operation)
136 safe_author = sanitize_display(entry.author or "")
137 author_col = f" {safe_author}" if safe_author else ""
138 return f"@{{{idx}:<3}} {new_short} ({old_short}) {when}{author_col} {safe_op}"
139
140
141 def _parse_date(value: str, flag: str) -> datetime.datetime:
142 """Parse *value* as ``YYYY-MM-DD`` into a UTC-aware datetime.
143
144 Raises SystemExit(USER_ERROR) for invalid format.
145 """
146 try:
147 d = datetime.date.fromisoformat(value)
148 except ValueError:
149 print(
150 f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — "
151 "expected YYYY-MM-DD.",
152 file=sys.stderr,
153 )
154 raise SystemExit(ExitCode.USER_ERROR)
155 return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc)
156
157
158 # ---------------------------------------------------------------------------
159 # Argument parser registration
160 # ---------------------------------------------------------------------------
161
162
163 def register(
164 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
165 ) -> None:
166 """Register the ``reflog`` subcommand."""
167 parser = subparsers.add_parser(
168 "reflog",
169 help="Show the history of HEAD and branch-ref movements.",
170 description=__doc__,
171 formatter_class=argparse.RawDescriptionHelpFormatter,
172 )
173 parser.add_argument(
174 "--branch", "-b", default=None,
175 help="Branch to show reflog for (default: HEAD).",
176 )
177 parser.add_argument(
178 "--limit", "-n", type=int, default=20,
179 help="Maximum number of entries to show (after filters, default: 20).",
180 )
181 parser.add_argument(
182 "--all", action="store_true", dest="all_refs",
183 help="List all refs that have a reflog.",
184 )
185 parser.add_argument(
186 "--operation", default=None, metavar="PATTERN", dest="operation_filter",
187 help="Filter to entries whose operation contains PATTERN (case-insensitive).",
188 )
189 parser.add_argument(
190 "--author", default=None, metavar="PATTERN", dest="author_filter",
191 help="Filter to entries whose author contains PATTERN (case-insensitive).",
192 )
193 parser.add_argument(
194 "--since", default=None, metavar="YYYY-MM-DD", dest="since",
195 help="Show only entries on or after this date.",
196 )
197 parser.add_argument(
198 "--until", default=None, metavar="YYYY-MM-DD", dest="until",
199 help="Show only entries on or before this date.",
200 )
201 parser.add_argument(
202 "--format", "-f", default="text", dest="fmt",
203 help="Output format: text or json.",
204 )
205 parser.add_argument(
206 "--json", action="store_const", const="json", dest="fmt",
207 help="Shorthand for --format json.",
208 )
209 parser.set_defaults(func=run)
210
211
212 # ---------------------------------------------------------------------------
213 # Command entry point
214 # ---------------------------------------------------------------------------
215
216
217 def run(args: argparse.Namespace) -> None:
218 """Show the history of HEAD and branch-ref movements.
219
220 Every time HEAD or a branch ref moves — commit, checkout, merge, reset,
221 cherry-pick, stash pop — Muse appends an entry to the reflog. Use this
222 command to find lost commits and undo accidental resets.
223
224 The ``--limit`` cap applies *after* all filters, so ``--limit 10
225 --operation commit`` always returns exactly 10 commit events (or fewer
226 if the history is shorter).
227
228 Agents should pass ``--json`` to receive a stable ``_ReflogResultJson``
229 object with metadata (ref, total, limit, entries).
230 """
231 branch: str | None = args.branch
232 limit: int = clamp_int(args.limit, 1, 100_000, "limit")
233 all_refs: bool = args.all_refs
234 fmt: str = args.fmt
235 operation_filter: str | None = args.operation_filter
236 author_filter: str | None = args.author_filter
237 since_str: str | None = args.since
238 until_str: str | None = args.until
239
240 if fmt not in ("text", "json"):
241 print(
242 f"❌ Unknown --format {sanitize_display(fmt)!r}. Choose text or json.",
243 file=sys.stderr,
244 )
245 raise SystemExit(ExitCode.USER_ERROR)
246
247 # Parse date filters before repo access so bad dates fail fast.
248 since_dt: datetime.datetime | None = (
249 _parse_date(since_str, "--since") if since_str else None
250 )
251 until_dt: datetime.datetime | None = (
252 _parse_date(until_str, "--until") if until_str else None
253 )
254 if since_dt and until_dt and since_dt > until_dt:
255 print("❌ --since must not be after --until.", file=sys.stderr)
256 raise SystemExit(ExitCode.USER_ERROR)
257
258 repo_root = require_repo()
259
260 # ── --all mode ────────────────────────────────────────────────────────────
261
262 if all_refs:
263 refs = list_reflog_refs(repo_root)
264 full_refs = [f"refs/heads/{r}" for r in refs]
265 if fmt == "json":
266 payload = _ReflogAllJson(refs=full_refs, count=len(full_refs))
267 print(json.dumps(payload, indent=2))
268 else:
269 if not refs:
270 print("No reflog entries found.")
271 return
272 print("Refs with reflog entries:")
273 for ref in refs:
274 print(f" refs/heads/{sanitize_display(ref)}")
275 return
276
277 # ── Branch validation ─────────────────────────────────────────────────────
278
279 if branch is not None:
280 try:
281 validate_branch_name(branch)
282 except ValueError as exc:
283 print(
284 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
285 file=sys.stderr,
286 )
287 raise SystemExit(ExitCode.USER_ERROR)
288
289 # ── Read all entries (limit is applied after filtering) ───────────────────
290
291 # Read a generous cap so post-filter limit can be satisfied.
292 raw_limit = min(limit * 50, 100_000)
293 entries = read_reflog(repo_root, branch=branch, limit=raw_limit)
294
295 # ── Apply filters ─────────────────────────────────────────────────────────
296
297 filtered: list[ReflogEntry] = entries
298
299 if operation_filter is not None:
300 needle = operation_filter.lower()
301 filtered = [e for e in filtered if needle in e.operation.lower()]
302
303 if author_filter is not None:
304 needle_a = author_filter.lower()
305 filtered = [e for e in filtered if needle_a in (e.author or "").lower()]
306
307 if since_dt is not None:
308 filtered = [e for e in filtered if e.timestamp >= since_dt]
309
310 if until_dt is not None:
311 # until is inclusive: entries on or before the end of until_dt day
312 until_end = until_dt + datetime.timedelta(days=1)
313 filtered = [e for e in filtered if e.timestamp < until_end]
314
315 # Apply display limit after all filters.
316 displayed = filtered[:limit]
317
318 # ── Output ────────────────────────────────────────────────────────────────
319
320 ref_name = f"refs/heads/{branch}" if branch else "HEAD"
321
322 if fmt == "json":
323 json_entries: list[_ReflogEntryJson] = [
324 _ReflogEntryJson(
325 index=idx,
326 new_id=e.new_id,
327 old_id=e.old_id,
328 timestamp=e.timestamp.isoformat(),
329 operation=e.operation,
330 author=e.author,
331 )
332 for idx, e in enumerate(displayed)
333 ]
334 payload_result = _ReflogResultJson(
335 ref=ref_name,
336 total=len(filtered),
337 limit=limit,
338 entries=json_entries,
339 )
340 print(json.dumps(payload_result, indent=2))
341 return
342
343 safe_label = sanitize_display(ref_name)
344 if not displayed:
345 if filtered:
346 print(f"No reflog entries for {safe_label} match the active filters.")
347 else:
348 print(f"No reflog entries for {safe_label}.")
349 return
350
351 print(f"Reflog for {safe_label} (newest first)\n")
352 for idx, entry in enumerate(displayed):
353 print(_fmt_entry(idx, entry))
354
355 if len(filtered) > limit:
356 remaining = len(filtered) - limit
357 print(f"\n … {remaining} older entry/entries — increase --limit to see more.")
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 28 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 31 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 50 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 102 days ago