gabriel / muse public
reflog.py python
1,536 lines 53.8 KB
Raw
sha256:bbaa4948a0e72cfa68f5d62f76f176f0c11153b513826d6fc84df500a11db79c feat: muse#54 item 4 — remote reflog sharing via push/pull Sonnet 5 minor ⚠ breaking 2 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, shelf 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_sha256> (<old_sha256>) <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 "duration_ms": 4.123,
49 "exit_code": 0,
50 "entries": [
51 {
52 "index": 0,
53 "new_id": "sha256:<64-hex>",
54 "old_id": "sha256:<64-hex or 000…>",
55 "timestamp": "2026-03-16T12:00:00+00:00",
56 "operation": "commit: add verse",
57 "author": "alice"
58 }
59 ]
60 }
61
62 ``new_id`` and ``old_id`` are always ``sha256:<64-hex>`` canonical form —
63 consistent with ``muse read-commit`` and all other ID-bearing commands.
64 The on-disk reflog stores bare hex; the JSON layer normalises them.
65
66 Exit codes
67 ----------
68 - 0 — success
69 - 1 — invalid arguments (bad branch, bad format, bad date)
70 - 2 — not inside a Muse repository
71 """
72
73 import argparse
74 import datetime
75 import json
76 import logging
77 import signal
78 import sys
79 import time
80 import types
81 from typing import TypedDict
82
83 from muse import __version__
84 from muse.core.envelope import EnvelopeJson, make_envelope
85 from muse.core.errors import ExitCode
86 from muse.core.reflog import ReflogEntry, categorize_operation, compact_reflog, delete_reflog_entry, expire_reflog, list_reflog_refs, read_reflog, read_remote_reflog, read_tag_reflog
87 from muse.core.repo import require_repo
88 from muse.core.validation import clamp_int, sanitize_display, validate_branch_name
89 from muse.core.types import NULL_COMMIT_ID, long_id
90 from muse.core.timing import start_timer
91
92
93 logger = logging.getLogger(__name__)
94
95 # ---------------------------------------------------------------------------
96 # JSON TypedDicts — stable machine-readable output schemas
97 # ---------------------------------------------------------------------------
98
99 class _ReflogEntryJson(TypedDict):
100 """One entry in the JSON reflog output."""
101
102 index: int
103 new_id: str
104 old_id: str
105 timestamp: str
106 operation: str
107 author: str
108
109 class _ReflogResultJson(EnvelopeJson):
110 """Top-level JSON object returned by ``muse reflog --json``.
111
112 All fields are always present. Envelope fields (``duration_ms``,
113 ``exit_code``, etc.) are command metadata — not reflog data — and are
114 always emitted regardless of filters applied.
115
116 ``new_id`` and ``old_id`` in each entry are ``sha256:<64-hex>``
117 canonical form, matching the Muse ID convention everywhere else.
118 """
119
120 ref: str
121 total: int
122 limit: int
123 entries: list[_ReflogEntryJson]
124
125 class _ReflogAllJson(EnvelopeJson):
126 """JSON object returned by ``muse reflog --all --json``."""
127
128 refs: list[str]
129 count: int
130
131 class _ExpireJson(EnvelopeJson):
132 """JSON object returned by ``muse reflog expire --json``."""
133
134 expired: int
135 kept: int
136 dry_run: bool
137 refs_processed: list[str]
138
139 class _CompactJson(EnvelopeJson):
140 """JSON object returned by ``muse reflog compact --json`` (muse#54 item 3)."""
141
142 compacted: int
143 remaining: int
144 dry_run: bool
145 ref: str
146
147 class _DeleteJson(EnvelopeJson):
148 """JSON object returned by ``muse reflog delete --json``."""
149
150 deleted: int
151 remaining: int
152 ref: str
153
154 class _ExistsJson(EnvelopeJson):
155 """JSON object returned by ``muse reflog exists --json``."""
156
157 exists: bool
158 count: int
159 ref: str
160
161 class _AnchorMatchJson(TypedDict):
162 """One match in ``muse reflog anchor`` output."""
163
164 ref: str
165 index: int
166 old_id: str
167 timestamp: str
168 operation: str
169 author: str
170
171 class _AnchorJson(EnvelopeJson):
172 """JSON object returned by ``muse reflog anchor --json`` (muse#54 item 8)."""
173
174 commit_id: str
175 matches: list[_AnchorMatchJson]
176 count: int
177
178 class _StatJson(EnvelopeJson):
179 """JSON object returned by ``muse reflog stat --json`` (muse#54 item 6)."""
180
181 ref: str
182 total_entries: int
183 operation_breakdown: dict[str, int]
184 most_active_author: str | None
185 reset_count: int
186 first_timestamp: str | None
187 last_timestamp: str | None
188
189 # ---------------------------------------------------------------------------
190 # Formatting helpers
191 # ---------------------------------------------------------------------------
192
193 def _fmt_entry(idx: int, entry: ReflogEntry) -> str:
194 """Format one reflog entry for terminal display.
195
196 All user-controlled fields (new_id, old_id, author, operation) are
197 passed through ``sanitize_display()`` to prevent ANSI injection.
198 IDs are rendered as the full ``sha256:<64-hex>`` canonical form —
199 consistency with all other Muse commands, and safe to pass directly
200 to other commands without risk of prefix collision.
201 """
202 new_full = sanitize_display(long_id(entry.new_id))
203 old_full = (
204 "initial"
205 if entry.old_id == NULL_COMMIT_ID
206 else sanitize_display(long_id(entry.old_id))
207 )
208 when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
209 safe_op = sanitize_display(entry.operation)
210 safe_author = sanitize_display(entry.author or "")
211 author_col = f" {safe_author}" if safe_author else ""
212 return f"@{{{idx}}} {new_full} ({old_full}) {when}{author_col} {safe_op}"
213
214
215 def _fmt_oneline(idx: int, entry: ReflogEntry) -> str:
216 """Format one reflog entry as a single line (muse#54 item 2).
217
218 Mirrors ``muse log --oneline``: ``@{N} <full_sha> <operation>``,
219 machine-parseable without the overhead of full JSON.
220 """
221 new_full = sanitize_display(long_id(entry.new_id))
222 safe_op = sanitize_display(entry.operation)
223 return f"@{{{idx}}} {new_full} {safe_op}"
224
225
226 def _print_entry_patch(repo_root, entry: ReflogEntry) -> None:
227 """Print the diff a reflog entry introduced (muse#54 item 2, ``--format patch``).
228
229 Delegates to the exact same diff engine as ``muse diff`` via a synthetic
230 ``argparse.Namespace``, matching the pattern used by ``run_diff``. An
231 entry whose ``old_id`` is the null commit (the very first commit on a
232 log) has nothing to diff against, so a note is printed instead of
233 attempting to resolve the all-zero sentinel as a real commit.
234 """
235 if entry.old_id == NULL_COMMIT_ID:
236 print(" (initial entry — no prior state to diff)\n")
237 return
238
239 from muse.cli.commands import diff as _diff_cmd
240
241 diff_args = argparse.Namespace(
242 commit_a=long_id(entry.old_id),
243 commit_b=long_id(entry.new_id),
244 paths=[],
245 staged=False,
246 unstaged=False,
247 stat=False,
248 text=True,
249 exit_code=False,
250 json_out=False,
251 shelf=False,
252 conflict=False,
253 )
254 _diff_cmd.run(diff_args)
255 print()
256
257 def _parse_date(value: str, flag: str) -> datetime.datetime:
258 """Parse *value* as ``YYYY-MM-DD`` into a UTC-aware datetime.
259
260 Raises SystemExit(USER_ERROR) for invalid format.
261 """
262 try:
263 d = datetime.date.fromisoformat(value)
264 except ValueError:
265 print(
266 f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — "
267 "expected YYYY-MM-DD.",
268 file=sys.stderr,
269 )
270 raise SystemExit(ExitCode.USER_ERROR)
271 return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc)
272
273 # ---------------------------------------------------------------------------
274 # Argument parser registration
275 # ---------------------------------------------------------------------------
276
277 def register(
278 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
279 ) -> None:
280 """Register the ``reflog`` subcommand tree."""
281 parser = subparsers.add_parser(
282 "reflog",
283 help="Show the history of HEAD and branch-ref movements.",
284 description=__doc__,
285 formatter_class=argparse.RawDescriptionHelpFormatter,
286 )
287 parser.add_argument(
288 "--branch", "-b", default=None,
289 help="Branch to show reflog for (default: HEAD).",
290 )
291 parser.add_argument(
292 "--tag", default=None, dest="tag_filter",
293 help="Version tag to show reflog for, e.g. 'muse reflog --tag v1.0.0' "
294 "(muse#54 item 5). Mutually exclusive with --branch/--all.",
295 )
296 parser.add_argument(
297 "--remote", default=None, dest="remote_filter",
298 help="Show a remote's tracking reflog instead of the local one, e.g. "
299 "'muse reflog --remote origin --branch main' (muse#54 item 4). "
300 "Requires --branch. Mutually exclusive with --tag/--all.",
301 )
302 parser.add_argument(
303 "--limit", type=int, default=20,
304 help="Maximum number of entries to show (after filters, default: 20).",
305 )
306 parser.add_argument(
307 "--all", action="store_true", dest="all_refs",
308 help="List all refs that have a reflog.",
309 )
310 parser.add_argument(
311 "--operation", default=None, metavar="PATTERN", dest="operation_filter",
312 help="Filter to entries whose operation contains PATTERN (case-insensitive).",
313 )
314 parser.add_argument(
315 "--author", default=None, metavar="PATTERN", dest="author_filter",
316 help="Filter to entries whose author contains PATTERN (case-insensitive).",
317 )
318 parser.add_argument(
319 "--since", default=None, metavar="YYYY-MM-DD", dest="since",
320 help="Show only entries on or after this date.",
321 )
322 parser.add_argument(
323 "--until", default=None, metavar="YYYY-MM-DD", dest="until",
324 help="Show only entries on or before this date.",
325 )
326 parser.add_argument(
327 "--json", "-j", action="store_true", dest="json_out",
328 help="Emit machine-readable JSON instead of human text.",
329 )
330 parser.add_argument(
331 "--format", choices=("text", "oneline", "patch"), default="text",
332 dest="format_mode",
333 help="Output format (muse#54 item 2): 'text' (default, one block per "
334 "entry), 'oneline' (@{N} <sha> <operation> per line, like "
335 "'muse log --oneline'), or 'patch' (each entry's oneline header "
336 "followed by the diff it introduced). Mutually exclusive with --json.",
337 )
338
339 # ── subcommands ───────────────────────────────────────────────────────────
340 subs = parser.add_subparsers(dest="reflog_subcommand", metavar="SUBCOMMAND")
341
342 # expire
343 expire_p = subs.add_parser(
344 "expire",
345 help="Prune reflog entries older than a configurable TTL.",
346 formatter_class=argparse.RawDescriptionHelpFormatter,
347 )
348 expire_p.add_argument(
349 "--expire-days", type=int, default=None, dest="expire_days",
350 help="Expire entries older than this many days (default: 90, or reflog.expire-days config).",
351 )
352 expire_p.add_argument(
353 "--branch", "-b", default=None,
354 help="Expire only the named branch log (default: HEAD log only).",
355 )
356 expire_p.add_argument(
357 "--all", action="store_true", dest="all_refs",
358 help="Expire HEAD log and all branch logs.",
359 )
360 expire_p.add_argument(
361 "--dry-run", action="store_true", dest="dry_run",
362 help="Report what would be removed without writing anything.",
363 )
364 expire_p.add_argument(
365 "--json", "-j", action="store_true", dest="json_out",
366 help="Emit machine-readable JSON.",
367 )
368 expire_p.set_defaults(func=run_expire)
369
370 # compact (muse#54 item 3)
371 compact_p = subs.add_parser(
372 "compact",
373 help="Merge consecutive same-category entries into summary entries.",
374 description=(
375 "Scans a log in storage order and merges each maximal run of 2+ "
376 "ADJACENT entries sharing an operation category (commit, reset, "
377 "merge, ...) into a single summary entry spanning the run -- "
378 "similar to how `muse rebase --squash` works at the commit "
379 "level. Keeps log files bounded by activity density (e.g. a "
380 "long fixup-commit series) rather than just age. Entries outside "
381 "a qualifying run, and malformed lines, are left untouched."
382 ),
383 formatter_class=argparse.RawDescriptionHelpFormatter,
384 )
385 compact_p.add_argument(
386 "--branch", "-b", default=None,
387 help="Compact the named branch log instead of HEAD.",
388 )
389 compact_p.add_argument(
390 "--dry-run", action="store_true", dest="dry_run",
391 help="Report what would be compacted without writing anything.",
392 )
393 compact_p.add_argument(
394 "--json", "-j", action="store_true", dest="json_out",
395 help="Emit machine-readable JSON.",
396 )
397 compact_p.set_defaults(func=run_compact)
398
399 # delete
400 delete_p = subs.add_parser(
401 "delete",
402 help="Delete one reflog entry by @{N} index, or all entries with --all.",
403 formatter_class=argparse.RawDescriptionHelpFormatter,
404 )
405 delete_p.add_argument(
406 "index", nargs="?", default=None, metavar="@{N}",
407 help="Entry index to delete (0 = newest). Omit when using --all.",
408 )
409 delete_p.add_argument(
410 "--all", action="store_true", dest="delete_all",
411 help="Delete all entries from the log (and remove the log file).",
412 )
413 delete_p.add_argument(
414 "--branch", "-b", default=None,
415 help="Target the named branch log instead of HEAD.",
416 )
417 delete_p.add_argument(
418 "--json", "-j", action="store_true", dest="json_out",
419 help="Emit machine-readable JSON.",
420 )
421 delete_p.set_defaults(func=run_delete)
422
423 # exists
424 exists_p = subs.add_parser(
425 "exists",
426 help="Exit 0 if a reflog has entries; exit 1 if none.",
427 formatter_class=argparse.RawDescriptionHelpFormatter,
428 )
429 exists_p.add_argument(
430 "--branch", "-b", default=None,
431 help="Check the named branch log instead of HEAD.",
432 )
433 exists_p.add_argument(
434 "--json", "-j", action="store_true", dest="json_out",
435 help="Emit machine-readable JSON.",
436 )
437 exists_p.set_defaults(func=run_exists)
438
439 # diff (muse#54 item 7)
440 diff_p = subs.add_parser(
441 "diff",
442 help="Diff between two reflog states, e.g. 'muse reflog diff 2 0'.",
443 description=(
444 "Point-in-time comparison between two reflog indices on the same "
445 "log, without manually resolving each @{N} to a SHA first. "
446 "'muse reflog diff 2 0' is equivalent to 'muse diff \"@{2}\" \"@{0}\"' "
447 "(or 'muse diff \"<branch>@{2}\" \"<branch>@{0}\"' with --branch)."
448 ),
449 formatter_class=argparse.RawDescriptionHelpFormatter,
450 )
451 diff_p.add_argument(
452 "index_a", type=int,
453 help="Older @{N} index to diff from.",
454 )
455 diff_p.add_argument(
456 "index_b", type=int, nargs="?", default=0,
457 help="Newer @{N} index to diff to (default: 0, the most recent entry).",
458 )
459 diff_p.add_argument(
460 "--branch", "-b", default=None,
461 help="Branch reflog to diff within (default: the HEAD log).",
462 )
463 diff_p.add_argument(
464 "--json", "-j", action="store_true", dest="json_out",
465 help="Emit machine-readable JSON instead of human text.",
466 )
467 diff_p.set_defaults(func=run_diff, json_out=False)
468
469 # stat (muse#54 item 6)
470 stat_p = subs.add_parser(
471 "stat",
472 help="Summary statistics for a reflog: operation breakdown, most active author, reset count.",
473 description=(
474 "Summarizes a branch's (or HEAD's) reflog: total entries, a count "
475 "of entries per operation type (commit/reset/merge/...), the most "
476 "active author, how many of the entries were resets, and the "
477 "first/last entry timestamps. Useful context when deciding whether "
478 "a branch is stable enough to merge."
479 ),
480 formatter_class=argparse.RawDescriptionHelpFormatter,
481 )
482 stat_p.add_argument(
483 "--branch", "-b", default=None,
484 help="Branch to summarize (default: HEAD).",
485 )
486 stat_p.add_argument(
487 "--json", "-j", action="store_true", dest="json_out",
488 help="Emit machine-readable JSON instead of human text.",
489 )
490 stat_p.set_defaults(func=run_stat)
491
492 # anchor (muse#54 item 8)
493 anchor_p = subs.add_parser(
494 "anchor",
495 help="Find all @{N} indices across branches where a commit appears.",
496 description=(
497 "Given a commit ID, search every branch's reflog (and the HEAD "
498 "log) for entries that moved a ref TO that commit, reporting the "
499 "ref name and @{N} index of each. Answers: 'how did I get to this "
500 "commit, and from how many different paths?' -- useful for "
501 "post-mortem analysis of complex rebase sequences."
502 ),
503 formatter_class=argparse.RawDescriptionHelpFormatter,
504 )
505 anchor_p.add_argument(
506 "commit_id", metavar="COMMIT",
507 help="Commit ID to search for (sha256:<hex> or bare hex).",
508 )
509 anchor_p.add_argument(
510 "--json", "-j", action="store_true", dest="json_out",
511 help="Emit machine-readable JSON instead of human text.",
512 )
513 anchor_p.set_defaults(func=run_anchor)
514
515 # watch (muse#54 item 10)
516 watch_p = subs.add_parser(
517 "watch",
518 help="Live-tail reflog entries as they're written.",
519 description=(
520 "Streams reflog events as NDJSON (one JSON object per line): a "
521 "'snapshot' event for every entry that already exists, then an "
522 "'appended' event each time a new entry is written, until "
523 "--timeout/--max-events elapses or the process is interrupted. "
524 "Pass --once to print the snapshot and exit immediately, without "
525 "waiting for new entries -- useful for scripting a baseline read."
526 ),
527 formatter_class=argparse.RawDescriptionHelpFormatter,
528 )
529 watch_p.add_argument(
530 "--branch", "-b", default=None,
531 help="Branch to watch (default: HEAD).",
532 )
533 watch_p.add_argument(
534 "--once", action="store_true",
535 help="Print the current snapshot and exit (no waiting for new entries).",
536 )
537 watch_p.add_argument(
538 "--timeout", type=float, default=None, metavar="SECONDS",
539 help="Stop watching after this many seconds (default: watch forever).",
540 )
541 watch_p.add_argument(
542 "--max-events", type=int, default=None, dest="max_events", metavar="N",
543 help="Stop after this many events total (snapshot + appended combined).",
544 )
545 watch_p.add_argument(
546 "--poll-interval", type=float, default=0.5, dest="poll_interval", metavar="SECONDS",
547 help="Seconds between checks for new entries (default: 0.5).",
548 )
549 watch_p.add_argument(
550 "--json", "-j", action="store_true", dest="json_out",
551 help="Emit NDJSON (default for this subcommand is already machine-readable; "
552 "--json is accepted for consistency but text mode prints the same "
553 "one-line-per-event format with colour/icons instead of raw JSON).",
554 )
555 watch_p.set_defaults(func=run_watch)
556
557 parser.set_defaults(func=run)
558
559 # ---------------------------------------------------------------------------
560 # expire subcommand
561 # ---------------------------------------------------------------------------
562
563 _DEFAULT_EXPIRE_DAYS = 90
564
565
566 def run_expire(args: argparse.Namespace) -> None:
567 """Prune reflog entries older than a configured TTL.
568
569 Reads ``reflog.expire-days`` from ``muse config`` when ``--expire-days``
570 is not supplied. Falls back to 90 days when neither is set.
571
572 The write is atomic: entries are filtered to a temp file and swapped in
573 with ``os.replace``. An empty log after expiry is deleted rather than
574 left as a zero-byte stub.
575
576 JSON schema::
577
578 {
579 "exit_code": 0,
580 "duration_ms": 4.2,
581 "expired": 47,
582 "kept": 12,
583 "dry_run": false,
584 "refs_processed": ["HEAD", "refs/heads/main", "refs/heads/dev"]
585 }
586
587 Exit codes::
588
589 0 Success (including when nothing expired).
590 1 Invalid arguments.
591 2 Not inside a Muse repository.
592 """
593 elapsed = start_timer()
594 json_out: bool = getattr(args, "json_out", False)
595 dry_run: bool = getattr(args, "dry_run", False)
596 all_refs: bool = getattr(args, "all_refs", False)
597 branch: str | None = getattr(args, "branch", None)
598 expire_days: int | None = getattr(args, "expire_days", None)
599
600 # --all and --branch together are ambiguous.
601 if all_refs and branch is not None:
602 print(
603 "❌ --all and --branch are mutually exclusive — "
604 "use one or the other.",
605 file=sys.stderr,
606 )
607 raise SystemExit(ExitCode.USER_ERROR)
608
609 repo_root = require_repo()
610
611 # Resolve expire_days: flag > config > default.
612 if expire_days is None:
613 from muse.cli.config import get_config_value
614 cfg_val = get_config_value("reflog.expire-days", repo_root)
615 if cfg_val is not None:
616 try:
617 expire_days = int(cfg_val)
618 except ValueError:
619 expire_days = _DEFAULT_EXPIRE_DAYS
620 else:
621 expire_days = _DEFAULT_EXPIRE_DAYS
622
623 if expire_days <= 0:
624 print(
625 f"❌ --expire-days must be a positive integer, got: {expire_days}",
626 file=sys.stderr,
627 )
628 raise SystemExit(ExitCode.USER_ERROR)
629
630 total_expired = 0
631 total_kept = 0
632 refs_processed: list[str] = []
633
634 if all_refs:
635 # Expire HEAD log + every branch log.
636 exp, kept = expire_reflog(repo_root, branch=None, expire_days=expire_days, dry_run=dry_run)
637 total_expired += exp
638 total_kept += kept
639 refs_processed.append("HEAD")
640
641 for br in list_reflog_refs(repo_root):
642 exp, kept = expire_reflog(repo_root, branch=br, expire_days=expire_days, dry_run=dry_run)
643 total_expired += exp
644 total_kept += kept
645 refs_processed.append(f"refs/heads/{br}")
646 else:
647 # Expire one log: either the named branch or HEAD.
648 exp, kept = expire_reflog(
649 repo_root,
650 branch=branch,
651 expire_days=expire_days,
652 dry_run=dry_run,
653 )
654 total_expired += exp
655 total_kept += kept
656 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
657 refs_processed.append(ref_label)
658
659 if json_out:
660 payload = _ExpireJson(
661 **make_envelope(elapsed),
662 expired=total_expired,
663 kept=total_kept,
664 dry_run=dry_run,
665 refs_processed=refs_processed,
666 )
667 print(json.dumps(payload))
668 return
669
670 if dry_run:
671 print(
672 f"(dry-run) Would expire {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
673 f"keep {total_kept}."
674 )
675 else:
676 print(
677 f"Expired {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
678 f"kept {total_kept}."
679 )
680
681
682 # ---------------------------------------------------------------------------
683 # compact subcommand (muse#54 item 3)
684 # ---------------------------------------------------------------------------
685
686 def run_compact(args: argparse.Namespace) -> None:
687 """Merge consecutive same-category entries into summary entries.
688
689 Scans the log in storage order and merges each maximal run of 2 or more
690 ADJACENT entries sharing an operation category (commit/reset/merge/...)
691 into one summary entry spanning the run, the same spirit as
692 ``muse rebase --squash`` at the commit level. Keeps log files bounded by
693 activity density (e.g. a long fixup-commit series) rather than just age.
694
695 JSON schema::
696
697 {
698 "exit_code": 0,
699 "duration_ms": 2.1,
700 "compacted": 5,
701 "remaining": 3,
702 "dry_run": false,
703 "ref": "HEAD"
704 }
705
706 Exit codes::
707
708 0 Success (including when nothing was compacted).
709 1 Invalid branch name.
710 2 Not inside a Muse repository.
711 """
712 elapsed = start_timer()
713 json_out: bool = getattr(args, "json_out", False)
714 dry_run: bool = getattr(args, "dry_run", False)
715 branch: str | None = getattr(args, "branch", None)
716
717 if branch is not None:
718 try:
719 validate_branch_name(branch)
720 except ValueError as exc:
721 print(
722 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
723 file=sys.stderr,
724 )
725 raise SystemExit(ExitCode.USER_ERROR)
726
727 repo_root = require_repo()
728 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
729
730 compacted, remaining = compact_reflog(repo_root, branch=branch, dry_run=dry_run)
731
732 if json_out:
733 payload = _CompactJson(
734 **make_envelope(elapsed),
735 compacted=compacted,
736 remaining=remaining,
737 dry_run=dry_run,
738 ref=ref_label,
739 )
740 print(json.dumps(payload))
741 return
742
743 safe_ref = sanitize_display(ref_label)
744 verb = "Would compact" if dry_run else "Compacted"
745 print(
746 f"{verb} {compacted} entr{'y' if compacted == 1 else 'ies'} on {safe_ref}, "
747 f"{remaining} remaining."
748 )
749
750
751 # ---------------------------------------------------------------------------
752 # delete subcommand
753 # ---------------------------------------------------------------------------
754
755 _AT_BRACE_PREFIX = "@{"
756 _AT_BRACE_SUFFIX = "}"
757
758
759 def _parse_at_index(raw: str) -> int:
760 """Parse ``@{N}`` into an integer index.
761
762 Raises ``ValueError`` for anything that doesn't match ``@{<digits>}``.
763 """
764 s = raw.strip()
765 if not (s.startswith(_AT_BRACE_PREFIX) and s.endswith(_AT_BRACE_SUFFIX)):
766 raise ValueError(f"Expected @{{N}} form, got: {s!r}")
767 inner = s[len(_AT_BRACE_PREFIX):-len(_AT_BRACE_SUFFIX)]
768 return int(inner) # raises ValueError for non-digits
769
770
771 def run_delete(args: argparse.Namespace) -> None:
772 """Delete one reflog entry by ``@{N}`` index, or all entries with ``--all``.
773
774 ``@{0}`` is the newest entry; ``@{N-1}`` is the oldest. The operation
775 is atomic: content is written to a temp file and renamed into place.
776 An empty log after deletion is removed rather than left as a zero-byte stub.
777
778 JSON schema::
779
780 {
781 "exit_code": 0,
782 "duration_ms": 1.1,
783 "deleted": 1,
784 "remaining": 5,
785 "ref": "HEAD"
786 }
787
788 Exit codes::
789
790 0 Success.
791 1 Invalid arguments or out-of-bounds index.
792 2 Not inside a Muse repository.
793 """
794 elapsed = start_timer()
795 json_out: bool = getattr(args, "json_out", False)
796 delete_all: bool = getattr(args, "delete_all", False)
797 branch: str | None = getattr(args, "branch", None)
798 raw_index: str | None = getattr(args, "index", None)
799
800 # Validate mutual exclusion.
801 if delete_all and raw_index is not None:
802 print(
803 "❌ --all and @{N} index are mutually exclusive — use one or the other.",
804 file=sys.stderr,
805 )
806 raise SystemExit(ExitCode.USER_ERROR)
807
808 if not delete_all and raw_index is None:
809 print(
810 "❌ Specify an entry to delete: `muse reflog delete @{N}` or `muse reflog delete --all`.",
811 file=sys.stderr,
812 )
813 raise SystemExit(ExitCode.USER_ERROR)
814
815 # Parse the index when given.
816 index: int | None = None
817 if raw_index is not None:
818 try:
819 index = _parse_at_index(raw_index)
820 except ValueError:
821 print(
822 f"❌ Invalid index {sanitize_display(raw_index)!r} — "
823 "expected @{N} where N is a non-negative integer.",
824 file=sys.stderr,
825 )
826 raise SystemExit(ExitCode.USER_ERROR)
827
828 # Validate branch name.
829 if branch is not None:
830 try:
831 validate_branch_name(branch)
832 except ValueError as exc:
833 print(
834 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
835 file=sys.stderr,
836 )
837 raise SystemExit(ExitCode.USER_ERROR)
838
839 repo_root = require_repo()
840 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
841
842 try:
843 deleted, remaining = delete_reflog_entry(repo_root, branch=branch, index=index)
844 except FileNotFoundError:
845 print(
846 f"❌ No reflog for {sanitize_display(ref_label)} — nothing to delete.",
847 file=sys.stderr,
848 )
849 raise SystemExit(ExitCode.USER_ERROR)
850 except IndexError as exc:
851 idx_val, total_val = exc.args[0], exc.args[1]
852 max_valid = total_val - 1
853 print(
854 f"❌ Index @{{{idx_val}}} is out of range — "
855 f"valid range: 0–{max_valid} ({total_val} entries).",
856 file=sys.stderr,
857 )
858 raise SystemExit(ExitCode.USER_ERROR)
859 except OSError as exc:
860 print(f"❌ Delete failed: {exc}", file=sys.stderr)
861 raise SystemExit(ExitCode.ERROR)
862
863 if json_out:
864 payload = _DeleteJson(
865 **make_envelope(elapsed),
866 deleted=deleted,
867 remaining=remaining,
868 ref=ref_label,
869 )
870 print(json.dumps(payload))
871 return
872
873 if delete_all:
874 print(f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} from {sanitize_display(ref_label)}.")
875 else:
876 print(
877 f"Deleted @{{{index}}} from {sanitize_display(ref_label)}. "
878 f"{remaining} entr{'y' if remaining == 1 else 'ies'} remaining."
879 )
880
881
882 # ---------------------------------------------------------------------------
883 # exists subcommand
884 # ---------------------------------------------------------------------------
885
886 def run_diff(args: argparse.Namespace) -> None:
887 """Diff between two reflog states on the same log (muse#54 item 7).
888
889 ``muse reflog diff 2 0`` resolves ``@{2}`` and ``@{0}`` on the HEAD log
890 (or the named branch's log with ``--branch``) and delegates to the exact
891 same diff engine as ``muse diff`` -- this is pure convenience over
892 manually constructing the ``@{N}`` spec strings yourself.
893
894 Exit codes::
895
896 0 Success (regardless of whether changes exist -- same as `muse diff`).
897 1 Invalid index (out of range) or either log file doesn't exist.
898 2 Not inside a Muse repository.
899 """
900 index_a: int = args.index_a
901 index_b: int = args.index_b
902 branch: str | None = getattr(args, "branch", None)
903 json_out: bool = args.json_out
904
905 prefix = sanitize_display(branch) if branch else ""
906 ref_a = f"{prefix}@{{{index_a}}}"
907 ref_b = f"{prefix}@{{{index_b}}}"
908
909 from muse.cli.commands import diff as _diff_cmd
910
911 diff_args = argparse.Namespace(
912 commit_a=ref_a,
913 commit_b=ref_b,
914 paths=[],
915 staged=False,
916 unstaged=False,
917 stat=False,
918 text=False,
919 exit_code=False,
920 json_out=json_out,
921 shelf=False,
922 conflict=False,
923 )
924 _diff_cmd.run(diff_args)
925
926
927 def run_exists(args: argparse.Namespace) -> None:
928 """Fast existence check — does this ref have any reflog entries?
929
930 Exits 0 when entries are present, 1 when none (scriptable without --json).
931
932 JSON schema::
933
934 {
935 "exit_code": 0,
936 "duration_ms": 0.3,
937 "ref": "HEAD",
938 "exists": true,
939 "count": 7
940 }
941
942 Exit codes::
943
944 0 Entries exist.
945 1 No entries (not an error — the same pattern as ``test``/``grep -q``).
946 2 Not inside a Muse repository.
947 """
948 elapsed = start_timer()
949 json_out: bool = getattr(args, "json_out", False)
950 branch: str | None = getattr(args, "branch", None)
951
952 repo_root = require_repo()
953 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
954
955 entries = read_reflog(repo_root, branch=branch, limit=10_000)
956 count = len(entries)
957 exists = count > 0
958
959 if json_out:
960 payload = _ExistsJson(
961 **make_envelope(elapsed, exit_code=0 if exists else 1),
962 exists=exists,
963 count=count,
964 ref=ref_label,
965 )
966 print(json.dumps(payload))
967 else:
968 safe_ref = sanitize_display(ref_label)
969 if exists:
970 print(f"{safe_ref} has {count} reflog entr{'y' if count == 1 else 'ies'}.")
971 else:
972 print(f"No reflog entries for {safe_ref}.")
973
974 if not exists:
975 raise SystemExit(1)
976
977
978 def run_stat(args: argparse.Namespace) -> None:
979 """Summary statistics for a reflog (muse#54 item 6).
980
981 Reads the full log (HEAD or ``--branch``) and reports: total entry
982 count, a breakdown of entries by operation category, the most active
983 author, how many entries were resets (a proxy for how often the branch
984 was "undone"), and the first/last entry timestamps.
985
986 JSON schema::
987
988 {
989 "ref": "refs/heads/main",
990 "total_entries": 12,
991 "operation_breakdown": {"commit": 8, "reset": 2, "merge": 2},
992 "most_active_author": "gabriel",
993 "reset_count": 2,
994 "first_timestamp": "2026-01-01T00:00:00+00:00",
995 "last_timestamp": "2026-03-16T12:00:00+00:00"
996 }
997
998 An empty reflog reports ``total_entries: 0``, an empty
999 ``operation_breakdown``, ``most_active_author: null``, and
1000 ``first_timestamp``/``last_timestamp`` both ``null``.
1001
1002 Exit codes::
1003
1004 0 Success (including an empty reflog).
1005 1 Invalid branch name.
1006 2 Not inside a Muse repository.
1007 """
1008 elapsed = start_timer()
1009 json_out: bool = getattr(args, "json_out", False)
1010 branch: str | None = getattr(args, "branch", None)
1011
1012 if branch is not None:
1013 try:
1014 validate_branch_name(branch)
1015 except ValueError as exc:
1016 print(
1017 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
1018 file=sys.stderr,
1019 )
1020 raise SystemExit(ExitCode.USER_ERROR)
1021
1022 repo_root = require_repo()
1023 ref_name = f"refs/heads/{branch}" if branch else "HEAD"
1024
1025 entries = read_reflog(repo_root, branch=branch, limit=100_000)
1026
1027 operation_breakdown: dict[str, int] = {}
1028 author_counts: dict[str, int] = {}
1029 reset_count = 0
1030 for entry in entries:
1031 category = categorize_operation(entry.operation)
1032 operation_breakdown[category] = operation_breakdown.get(category, 0) + 1
1033 if category == "reset":
1034 reset_count += 1
1035 author = entry.author or ""
1036 if author:
1037 author_counts[author] = author_counts.get(author, 0) + 1
1038
1039 most_active_author: str | None = None
1040 if author_counts:
1041 most_active_author = max(author_counts.items(), key=lambda kv: kv[1])[0]
1042
1043 # read_reflog returns newest-first.
1044 first_timestamp = entries[-1].timestamp.isoformat() if entries else None
1045 last_timestamp = entries[0].timestamp.isoformat() if entries else None
1046
1047 if json_out:
1048 payload = _StatJson(
1049 **make_envelope(elapsed),
1050 ref=ref_name,
1051 total_entries=len(entries),
1052 operation_breakdown=operation_breakdown,
1053 most_active_author=most_active_author,
1054 reset_count=reset_count,
1055 first_timestamp=first_timestamp,
1056 last_timestamp=last_timestamp,
1057 )
1058 print(json.dumps(payload))
1059 return
1060
1061 safe_ref = sanitize_display(ref_name)
1062 if not entries:
1063 print(f"No reflog entries for {safe_ref}.")
1064 return
1065
1066 print(f"Reflog stats for {safe_ref}\n")
1067 print(f" Total entries: {len(entries)}")
1068 print(f" Reset count: {reset_count}")
1069 print(f" Most active: {sanitize_display(most_active_author) if most_active_author else '(none)'}")
1070 print(f" First entry: {first_timestamp}")
1071 print(f" Last entry: {last_timestamp}")
1072 print("\n Operation breakdown:")
1073 for category, count in sorted(operation_breakdown.items(), key=lambda kv: -kv[1]):
1074 print(f" {sanitize_display(category):<12} {count}")
1075
1076
1077 def run_anchor(args: argparse.Namespace) -> None:
1078 """Find all ``@{N}`` indices across refs where a commit appears (muse#54 item 8).
1079
1080 Searches the HEAD log and every branch's reflog for entries whose
1081 ``new_id`` matches the given commit — i.e. every point in history where
1082 some ref moved *to* this commit. Reports the owning ref and the ``@{N}``
1083 index within that ref's own log (index is relative to that log, not
1084 global).
1085
1086 JSON schema::
1087
1088 {
1089 "commit_id": "sha256:<64-hex>",
1090 "count": 2,
1091 "matches": [
1092 {
1093 "ref": "refs/heads/main",
1094 "index": 0,
1095 "old_id": "sha256:<64-hex>",
1096 "timestamp": "2026-03-16T12:00:00+00:00",
1097 "operation": "commit: add verse",
1098 "author": "gabriel"
1099 }
1100 ]
1101 }
1102
1103 An unmatched commit reports ``count: 0`` and an empty ``matches`` list
1104 — this is not an error (the commit may exist but never have been the
1105 target of a logged ref movement, e.g. it's a parent of HEAD but was
1106 never itself checked out).
1107
1108 Exit codes::
1109
1110 0 Success (including zero matches).
1111 2 Not inside a Muse repository.
1112 """
1113 elapsed = start_timer()
1114 json_out: bool = getattr(args, "json_out", False)
1115 target = long_id(args.commit_id)
1116
1117 repo_root = require_repo()
1118
1119 matches: list[_AnchorMatchJson] = []
1120
1121 def _scan(ref_label: str, branch: str | None) -> None:
1122 for idx, entry in enumerate(read_reflog(repo_root, branch=branch, limit=100_000)):
1123 if long_id(entry.new_id) == target:
1124 matches.append(_AnchorMatchJson(
1125 ref=ref_label,
1126 index=idx,
1127 old_id=long_id(entry.old_id),
1128 timestamp=entry.timestamp.isoformat(),
1129 operation=entry.operation,
1130 author=entry.author,
1131 ))
1132
1133 _scan("HEAD", None)
1134 for br in list_reflog_refs(repo_root):
1135 _scan(f"refs/heads/{br}", br)
1136
1137 if json_out:
1138 payload = _AnchorJson(
1139 **make_envelope(elapsed),
1140 commit_id=target,
1141 matches=matches,
1142 count=len(matches),
1143 )
1144 print(json.dumps(payload))
1145 return
1146
1147 safe_commit = sanitize_display(target)
1148 if not matches:
1149 print(f"No reflog entries found for {safe_commit}.")
1150 return
1151
1152 print(f"{safe_commit} was anchored at:\n")
1153 for m in matches:
1154 print(f" {sanitize_display(m['ref'])}@{{{m['index']}}} {m['timestamp']} {sanitize_display(m['author'])} {sanitize_display(m['operation'])}")
1155
1156
1157 # ---------------------------------------------------------------------------
1158 # watch subcommand (muse#54 item 10)
1159 # ---------------------------------------------------------------------------
1160
1161 def _watch_event(event_type: str, index: int, entry: ReflogEntry) -> dict:
1162 """Build the JSON/NDJSON payload for one reflog-watch event."""
1163 return {
1164 "schema_version": __version__,
1165 "event_type": event_type,
1166 "index": index,
1167 "new_id": long_id(entry.new_id),
1168 "old_id": long_id(entry.old_id),
1169 "timestamp": entry.timestamp.isoformat(),
1170 "operation": entry.operation,
1171 "author": entry.author,
1172 }
1173
1174
1175 def _print_watch_event(ev: dict, json_out: bool) -> None:
1176 """Print one watch event, either as raw NDJSON or a human-readable line."""
1177 if json_out:
1178 print(json.dumps(ev), flush=True)
1179 return
1180 icon = "📸" if ev["event_type"] == "snapshot" else "➕"
1181 new_full = sanitize_display(ev["new_id"])
1182 safe_op = sanitize_display(ev["operation"])
1183 safe_author = sanitize_display(ev["author"] or "")
1184 author_col = f" {safe_author}" if safe_author else ""
1185 print(
1186 f"{icon} @{{{ev['index']}}} {new_full}{author_col} {safe_op}",
1187 flush=True,
1188 )
1189
1190
1191 def run_watch(args: argparse.Namespace) -> None:
1192 """Live-tail reflog entries as they're written (muse#54 item 10).
1193
1194 Emits a ``"snapshot"`` event for every entry that already exists (oldest
1195 first), then polls for newly appended entries and emits an
1196 ``"appended"`` event for each, until ``--timeout``/``--max-events``
1197 elapses or the process is interrupted. ``--once`` prints just the
1198 snapshot and exits — useful for a deterministic baseline read without
1199 waiting on real-time activity.
1200
1201 NDJSON schema (one object per line, ``--json``)::
1202
1203 {
1204 "schema_version": "0.2.1",
1205 "event_type": "snapshot" | "appended",
1206 "index": 0,
1207 "new_id": "sha256:<64-hex>",
1208 "old_id": "sha256:<64-hex or 000...>",
1209 "timestamp": "2026-03-16T12:00:00+00:00",
1210 "operation": "commit: add verse",
1211 "author": "gabriel"
1212 }
1213
1214 Exit codes::
1215
1216 0 Normal exit (--once, --timeout, --max-events, SIGINT, or SIGTERM).
1217 1 Invalid arguments (bad branch, bad --poll-interval/--timeout/--max-events).
1218 2 Not inside a Muse repository.
1219 """
1220 json_out: bool = getattr(args, "json_out", False)
1221 branch: str | None = getattr(args, "branch", None)
1222 once: bool = getattr(args, "once", False)
1223 timeout: float | None = getattr(args, "timeout", None)
1224 max_events: int | None = getattr(args, "max_events", None)
1225 poll_interval: float = getattr(args, "poll_interval", 0.5)
1226
1227 if branch is not None:
1228 try:
1229 validate_branch_name(branch)
1230 except ValueError as exc:
1231 print(
1232 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
1233 file=sys.stderr,
1234 )
1235 raise SystemExit(ExitCode.USER_ERROR)
1236
1237 if poll_interval <= 0:
1238 print("❌ --poll-interval must be > 0.", file=sys.stderr)
1239 raise SystemExit(ExitCode.USER_ERROR)
1240
1241 if timeout is not None and timeout < 0:
1242 print("❌ --timeout must be >= 0.", file=sys.stderr)
1243 raise SystemExit(ExitCode.USER_ERROR)
1244
1245 if max_events is not None and max_events < 1:
1246 print("❌ --max-events must be >= 1.", file=sys.stderr)
1247 raise SystemExit(ExitCode.USER_ERROR)
1248
1249 repo_root = require_repo()
1250
1251 emitted = 0
1252
1253 def _over_budget() -> bool:
1254 return max_events is not None and emitted >= max_events
1255
1256 # ── Initial snapshot (oldest first, so event order matches real history) ──
1257
1258 existing = read_reflog(repo_root, branch=branch, limit=100_000)
1259 total_seen = len(existing)
1260 for idx, entry in enumerate(reversed(existing)):
1261 if _over_budget():
1262 return
1263 real_index = total_seen - 1 - idx
1264 _print_watch_event(_watch_event("snapshot", real_index, entry), json_out)
1265 emitted += 1
1266
1267 if once or _over_budget():
1268 return
1269
1270 # ── Poll for newly appended entries ────────────────────────────────────────
1271
1272 prev_sigterm = signal.getsignal(signal.SIGTERM)
1273
1274 def _handle_sigterm(sig: int, frame: "types.FrameType | None") -> None: # noqa: ARG001
1275 raise SystemExit(0)
1276
1277 signal.signal(signal.SIGTERM, _handle_sigterm)
1278
1279 start = time.monotonic()
1280 try:
1281 while True:
1282 if timeout is not None and (time.monotonic() - start) >= timeout:
1283 return
1284 if _over_budget():
1285 return
1286 time.sleep(poll_interval)
1287
1288 current = read_reflog(repo_root, branch=branch, limit=100_000)
1289 new_count = len(current) - total_seen
1290 if new_count <= 0:
1291 continue
1292
1293 # current is newest-first; the first new_count entries are new.
1294 # Emit oldest-of-the-new-batch first to match chronological order.
1295 # @{N} indices are relative to `current` as read just now (0 =
1296 # newest) -- the same convention `muse reflog --json` uses.
1297 new_entries = list(reversed(current[:new_count]))
1298 for offset, entry in enumerate(new_entries):
1299 if _over_budget():
1300 return
1301 real_index = new_count - 1 - offset
1302 _print_watch_event(_watch_event("appended", real_index, entry), json_out)
1303 emitted += 1
1304 total_seen = len(current)
1305 except KeyboardInterrupt:
1306 return
1307 finally:
1308 signal.signal(signal.SIGTERM, prev_sigterm)
1309
1310
1311 # ---------------------------------------------------------------------------
1312 # Command entry point
1313 # ---------------------------------------------------------------------------
1314
1315 def run(args: argparse.Namespace) -> None:
1316 """Show the history of HEAD and branch-ref movements.
1317
1318 Every time HEAD or a branch ref moves — commit, checkout, merge, reset,
1319 cherry-pick, shelf pop — Muse appends an entry to the reflog. Use this
1320 command to find lost commits and undo accidental resets.
1321
1322 The ``--limit`` cap applies *after* all filters, so ``--limit 10
1323 --operation commit`` always returns exactly 10 commit events (or fewer
1324 if the history is shorter).
1325
1326 Agent quickstart::
1327
1328 muse reflog --json
1329 muse reflog --branch dev --json
1330 muse reflog --operation commit --limit 5 --json
1331 muse reflog --all --json
1332
1333 JSON fields::
1334
1335 ref str full ref name (e.g. "refs/heads/main" or "HEAD")
1336 total int number of entries after all filters applied
1337 limit int display cap that was requested
1338 entries list [{index, new_id, old_id, timestamp, operation, author}]
1339
1340 JSON fields (--all mode)::
1341
1342 refs list full ref names that have a reflog
1343 count int number of refs
1344
1345 Exit codes::
1346
1347 0 Success.
1348 1 Invalid arguments (bad branch, bad format, bad date).
1349 2 Not inside a Muse repository.
1350 """
1351 elapsed = start_timer()
1352 branch: str | None = args.branch
1353 tag_filter: str | None = getattr(args, "tag_filter", None)
1354 remote_filter: str | None = getattr(args, "remote_filter", None)
1355 limit: int = clamp_int(args.limit, 1, 100_000, "limit")
1356 all_refs: bool = args.all_refs
1357 json_out: bool = args.json_out
1358 operation_filter: str | None = args.operation_filter
1359 author_filter: str | None = args.author_filter
1360 since_str: str | None = args.since
1361 until_str: str | None = args.until
1362 format_mode: str = getattr(args, "format_mode", "text")
1363
1364 if json_out and format_mode != "text":
1365 print(
1366 "❌ --format and --json are mutually exclusive — "
1367 "JSON output already has its own stable schema.",
1368 file=sys.stderr,
1369 )
1370 raise SystemExit(ExitCode.USER_ERROR)
1371
1372 # Parse date filters before repo access so bad dates fail fast.
1373 since_dt: datetime.datetime | None = (
1374 _parse_date(since_str, "--since") if since_str else None
1375 )
1376 until_dt: datetime.datetime | None = (
1377 _parse_date(until_str, "--until") if until_str else None
1378 )
1379 if since_dt and until_dt and since_dt > until_dt:
1380 print("❌ --since must not be after --until.", file=sys.stderr)
1381 raise SystemExit(ExitCode.USER_ERROR)
1382
1383 if tag_filter is not None and (branch is not None or all_refs):
1384 print(
1385 "❌ --tag is mutually exclusive with --branch/--all.",
1386 file=sys.stderr,
1387 )
1388 raise SystemExit(ExitCode.USER_ERROR)
1389
1390 if remote_filter is not None:
1391 if tag_filter is not None or all_refs:
1392 print(
1393 "❌ --remote is mutually exclusive with --tag/--all.",
1394 file=sys.stderr,
1395 )
1396 raise SystemExit(ExitCode.USER_ERROR)
1397 if branch is None:
1398 print(
1399 "❌ --remote requires --branch — a remote's reflog is tracked "
1400 "per branch, e.g. 'muse reflog --remote origin --branch main'.",
1401 file=sys.stderr,
1402 )
1403 raise SystemExit(ExitCode.USER_ERROR)
1404 if "/" in remote_filter or "\\" in remote_filter or ".." in remote_filter:
1405 print(
1406 "❌ Invalid remote name — path separators and '..' are not allowed.",
1407 file=sys.stderr,
1408 )
1409 raise SystemExit(ExitCode.USER_ERROR)
1410
1411 repo_root = require_repo()
1412
1413 # ── --all mode ────────────────────────────────────────────────────────────
1414
1415 if all_refs:
1416 refs = list_reflog_refs(repo_root)
1417 full_refs = [f"refs/heads/{r}" for r in refs]
1418 if json_out:
1419 payload = _ReflogAllJson(
1420 **make_envelope(elapsed),
1421 refs=full_refs,
1422 count=len(full_refs),
1423 )
1424 print(json.dumps(payload))
1425 else:
1426 if not refs:
1427 print("No reflog entries found.")
1428 return
1429 print("Refs with reflog entries:")
1430 for ref in refs:
1431 print(f" refs/heads/{sanitize_display(ref)}")
1432 return
1433
1434 # ── Branch validation ─────────────────────────────────────────────────────
1435
1436 if branch is not None:
1437 try:
1438 validate_branch_name(branch)
1439 except ValueError as exc:
1440 print(
1441 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
1442 file=sys.stderr,
1443 )
1444 raise SystemExit(ExitCode.USER_ERROR)
1445
1446 # ── Read all entries (limit is applied after filtering) ───────────────────
1447
1448 # Read a generous cap so post-filter limit can be satisfied.
1449 raw_limit = min(limit * 50, 100_000)
1450 if tag_filter is not None:
1451 entries = read_tag_reflog(repo_root, tag_filter, limit=raw_limit)
1452 elif remote_filter is not None:
1453 entries = read_remote_reflog(repo_root, remote_filter, branch, limit=raw_limit)
1454 else:
1455 entries = read_reflog(repo_root, branch=branch, limit=raw_limit)
1456
1457 # ── Apply filters ─────────────────────────────────────────────────────────
1458
1459 filtered: list[ReflogEntry] = entries
1460
1461 if operation_filter is not None:
1462 needle = operation_filter.lower()
1463 filtered = [e for e in filtered if needle in e.operation.lower()]
1464
1465 if author_filter is not None:
1466 needle_a = author_filter.lower()
1467 filtered = [e for e in filtered if needle_a in (e.author or "").lower()]
1468
1469 if since_dt is not None:
1470 filtered = [e for e in filtered if e.timestamp >= since_dt]
1471
1472 if until_dt is not None:
1473 # until is inclusive: entries on or before the end of until_dt day
1474 until_end = until_dt + datetime.timedelta(days=1)
1475 filtered = [e for e in filtered if e.timestamp < until_end]
1476
1477 # Apply display limit after all filters.
1478 displayed = filtered[:limit]
1479
1480 # ── Output ────────────────────────────────────────────────────────────────
1481
1482 ref_name = (
1483 f"refs/tags/{tag_filter}" if tag_filter
1484 else f"refs/remotes/{remote_filter}/{branch}" if remote_filter
1485 else f"refs/heads/{branch}" if branch
1486 else "HEAD"
1487 )
1488
1489 if json_out:
1490 json_entries: list[_ReflogEntryJson] = [
1491 _ReflogEntryJson(
1492 index=idx,
1493 new_id=long_id(e.new_id),
1494 old_id=long_id(e.old_id),
1495 timestamp=e.timestamp.isoformat(),
1496 operation=e.operation,
1497 author=e.author,
1498 )
1499 for idx, e in enumerate(displayed)
1500 ]
1501 payload_result = _ReflogResultJson(
1502 **make_envelope(elapsed),
1503 ref=ref_name,
1504 total=len(filtered),
1505 limit=limit,
1506 entries=json_entries,
1507 )
1508 print(json.dumps(payload_result))
1509 return
1510
1511 safe_label = sanitize_display(ref_name)
1512 if not displayed:
1513 if filtered:
1514 print(f"No reflog entries for {safe_label} match the active filters.")
1515 else:
1516 print(f"No reflog entries for {safe_label}.")
1517 return
1518
1519 if format_mode == "oneline":
1520 for idx, entry in enumerate(displayed):
1521 print(_fmt_oneline(idx, entry))
1522 return
1523
1524 if format_mode == "patch":
1525 for idx, entry in enumerate(displayed):
1526 print(_fmt_oneline(idx, entry))
1527 _print_entry_patch(repo_root, entry)
1528 return
1529
1530 print(f"Reflog for {safe_label} (newest first)\n")
1531 for idx, entry in enumerate(displayed):
1532 print(_fmt_entry(idx, entry))
1533
1534 if len(filtered) > limit:
1535 remaining = len(filtered) - limit
1536 print(f"\n … {remaining} older entry/entries — increase --limit to see more.")
File History 1 commit
sha256:bbaa4948a0e72cfa68f5d62f76f176f0c11153b513826d6fc84df500a11db79c feat: muse#54 item 4 — remote reflog sharing via push/pull Sonnet 5 minor ⚠ 2 days ago