gabriel / muse public
reflog.py python
784 lines 26.6 KB
Raw
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 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_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 "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 sys
78 import time
79 from typing import TypedDict
80
81 from muse.core.envelope import EnvelopeJson, make_envelope
82 from muse.core.errors import ExitCode
83 from muse.core.reflog import ReflogEntry, delete_reflog_entry, expire_reflog, list_reflog_refs, read_reflog
84 from muse.core.repo import require_repo
85 from muse.core.validation import clamp_int, sanitize_display, validate_branch_name
86 from muse.core.types import NULL_COMMIT_ID, long_id, short_id
87 from muse.core.timing import start_timer
88
89
90 logger = logging.getLogger(__name__)
91
92 # ---------------------------------------------------------------------------
93 # JSON TypedDicts — stable machine-readable output schemas
94 # ---------------------------------------------------------------------------
95
96 class _ReflogEntryJson(TypedDict):
97 """One entry in the JSON reflog output."""
98
99 index: int
100 new_id: str
101 old_id: str
102 timestamp: str
103 operation: str
104 author: str
105
106 class _ReflogResultJson(EnvelopeJson):
107 """Top-level JSON object returned by ``muse reflog --json``.
108
109 All fields are always present. Envelope fields (``duration_ms``,
110 ``exit_code``, etc.) are command metadata — not reflog data — and are
111 always emitted regardless of filters applied.
112
113 ``new_id`` and ``old_id`` in each entry are ``sha256:<64-hex>``
114 canonical form, matching the Muse ID convention everywhere else.
115 """
116
117 ref: str
118 total: int
119 limit: int
120 entries: list[_ReflogEntryJson]
121
122 class _ReflogAllJson(EnvelopeJson):
123 """JSON object returned by ``muse reflog --all --json``."""
124
125 refs: list[str]
126 count: int
127
128 class _ExpireJson(EnvelopeJson):
129 """JSON object returned by ``muse reflog expire --json``."""
130
131 expired: int
132 kept: int
133 dry_run: bool
134 refs_processed: list[str]
135
136 class _DeleteJson(EnvelopeJson):
137 """JSON object returned by ``muse reflog delete --json``."""
138
139 deleted: int
140 remaining: int
141 ref: str
142
143 class _ExistsJson(EnvelopeJson):
144 """JSON object returned by ``muse reflog exists --json``."""
145
146 exists: bool
147 count: int
148 ref: str
149
150 # ---------------------------------------------------------------------------
151 # Formatting helpers
152 # ---------------------------------------------------------------------------
153
154 def _fmt_entry(idx: int, entry: ReflogEntry, short: int = 12) -> str:
155 """Format one reflog entry for terminal display.
156
157 All user-controlled fields (new_id, old_id, author, operation) are
158 passed through ``sanitize_display()`` to prevent ANSI injection.
159 Short IDs are rendered as ``sha256:<12-hex>`` (19 chars) for
160 consistency with all other Muse commands.
161 """
162 new_short = sanitize_display(short_id(entry.new_id))
163 old_short = (
164 "initial"
165 if entry.old_id == NULL_COMMIT_ID
166 else sanitize_display(short_id(entry.old_id))
167 )
168 when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")
169 safe_op = sanitize_display(entry.operation)
170 safe_author = sanitize_display(entry.author or "")
171 author_col = f" {safe_author}" if safe_author else ""
172 return f"@{{{idx}}} {new_short} ({old_short}) {when}{author_col} {safe_op}"
173
174 def _parse_date(value: str, flag: str) -> datetime.datetime:
175 """Parse *value* as ``YYYY-MM-DD`` into a UTC-aware datetime.
176
177 Raises SystemExit(USER_ERROR) for invalid format.
178 """
179 try:
180 d = datetime.date.fromisoformat(value)
181 except ValueError:
182 print(
183 f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — "
184 "expected YYYY-MM-DD.",
185 file=sys.stderr,
186 )
187 raise SystemExit(ExitCode.USER_ERROR)
188 return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc)
189
190 # ---------------------------------------------------------------------------
191 # Argument parser registration
192 # ---------------------------------------------------------------------------
193
194 def register(
195 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
196 ) -> None:
197 """Register the ``reflog`` subcommand tree."""
198 parser = subparsers.add_parser(
199 "reflog",
200 help="Show the history of HEAD and branch-ref movements.",
201 description=__doc__,
202 formatter_class=argparse.RawDescriptionHelpFormatter,
203 )
204 parser.add_argument(
205 "--branch", "-b", default=None,
206 help="Branch to show reflog for (default: HEAD).",
207 )
208 parser.add_argument(
209 "--limit", type=int, default=20,
210 help="Maximum number of entries to show (after filters, default: 20).",
211 )
212 parser.add_argument(
213 "--all", action="store_true", dest="all_refs",
214 help="List all refs that have a reflog.",
215 )
216 parser.add_argument(
217 "--operation", default=None, metavar="PATTERN", dest="operation_filter",
218 help="Filter to entries whose operation contains PATTERN (case-insensitive).",
219 )
220 parser.add_argument(
221 "--author", default=None, metavar="PATTERN", dest="author_filter",
222 help="Filter to entries whose author contains PATTERN (case-insensitive).",
223 )
224 parser.add_argument(
225 "--since", default=None, metavar="YYYY-MM-DD", dest="since",
226 help="Show only entries on or after this date.",
227 )
228 parser.add_argument(
229 "--until", default=None, metavar="YYYY-MM-DD", dest="until",
230 help="Show only entries on or before this date.",
231 )
232 parser.add_argument(
233 "--json", "-j", action="store_true", dest="json_out",
234 help="Emit machine-readable JSON instead of human text.",
235 )
236
237 # ── subcommands ───────────────────────────────────────────────────────────
238 subs = parser.add_subparsers(dest="reflog_subcommand", metavar="SUBCOMMAND")
239
240 # expire
241 expire_p = subs.add_parser(
242 "expire",
243 help="Prune reflog entries older than a configurable TTL.",
244 formatter_class=argparse.RawDescriptionHelpFormatter,
245 )
246 expire_p.add_argument(
247 "--expire-days", type=int, default=None, dest="expire_days",
248 help="Expire entries older than this many days (default: 90, or reflog.expire-days config).",
249 )
250 expire_p.add_argument(
251 "--branch", "-b", default=None,
252 help="Expire only the named branch log (default: HEAD log only).",
253 )
254 expire_p.add_argument(
255 "--all", action="store_true", dest="all_refs",
256 help="Expire HEAD log and all branch logs.",
257 )
258 expire_p.add_argument(
259 "--dry-run", action="store_true", dest="dry_run",
260 help="Report what would be removed without writing anything.",
261 )
262 expire_p.add_argument(
263 "--json", "-j", action="store_true", dest="json_out",
264 help="Emit machine-readable JSON.",
265 )
266 expire_p.set_defaults(func=run_expire)
267
268 # delete
269 delete_p = subs.add_parser(
270 "delete",
271 help="Delete one reflog entry by @{N} index, or all entries with --all.",
272 formatter_class=argparse.RawDescriptionHelpFormatter,
273 )
274 delete_p.add_argument(
275 "index", nargs="?", default=None, metavar="@{N}",
276 help="Entry index to delete (0 = newest). Omit when using --all.",
277 )
278 delete_p.add_argument(
279 "--all", action="store_true", dest="delete_all",
280 help="Delete all entries from the log (and remove the log file).",
281 )
282 delete_p.add_argument(
283 "--branch", "-b", default=None,
284 help="Target the named branch log instead of HEAD.",
285 )
286 delete_p.add_argument(
287 "--json", "-j", action="store_true", dest="json_out",
288 help="Emit machine-readable JSON.",
289 )
290 delete_p.set_defaults(func=run_delete)
291
292 # exists
293 exists_p = subs.add_parser(
294 "exists",
295 help="Exit 0 if a reflog has entries; exit 1 if none.",
296 formatter_class=argparse.RawDescriptionHelpFormatter,
297 )
298 exists_p.add_argument(
299 "--branch", "-b", default=None,
300 help="Check the named branch log instead of HEAD.",
301 )
302 exists_p.add_argument(
303 "--json", "-j", action="store_true", dest="json_out",
304 help="Emit machine-readable JSON.",
305 )
306 exists_p.set_defaults(func=run_exists)
307
308 parser.set_defaults(func=run)
309
310 # ---------------------------------------------------------------------------
311 # expire subcommand
312 # ---------------------------------------------------------------------------
313
314 _DEFAULT_EXPIRE_DAYS = 90
315
316
317 def run_expire(args: argparse.Namespace) -> None:
318 """Prune reflog entries older than a configured TTL.
319
320 Reads ``reflog.expire-days`` from ``muse config`` when ``--expire-days``
321 is not supplied. Falls back to 90 days when neither is set.
322
323 The write is atomic: entries are filtered to a temp file and swapped in
324 with ``os.replace``. An empty log after expiry is deleted rather than
325 left as a zero-byte stub.
326
327 JSON schema::
328
329 {
330 "exit_code": 0,
331 "duration_ms": 4.2,
332 "expired": 47,
333 "kept": 12,
334 "dry_run": false,
335 "refs_processed": ["HEAD", "refs/heads/main", "refs/heads/dev"]
336 }
337
338 Exit codes::
339
340 0 Success (including when nothing expired).
341 1 Invalid arguments.
342 2 Not inside a Muse repository.
343 """
344 elapsed = start_timer()
345 json_out: bool = getattr(args, "json_out", False)
346 dry_run: bool = getattr(args, "dry_run", False)
347 all_refs: bool = getattr(args, "all_refs", False)
348 branch: str | None = getattr(args, "branch", None)
349 expire_days: int | None = getattr(args, "expire_days", None)
350
351 # --all and --branch together are ambiguous.
352 if all_refs and branch is not None:
353 print(
354 "❌ --all and --branch are mutually exclusive — "
355 "use one or the other.",
356 file=sys.stderr,
357 )
358 raise SystemExit(ExitCode.USER_ERROR)
359
360 repo_root = require_repo()
361
362 # Resolve expire_days: flag > config > default.
363 if expire_days is None:
364 from muse.cli.config import get_config_value
365 cfg_val = get_config_value("reflog.expire-days", repo_root)
366 if cfg_val is not None:
367 try:
368 expire_days = int(cfg_val)
369 except ValueError:
370 expire_days = _DEFAULT_EXPIRE_DAYS
371 else:
372 expire_days = _DEFAULT_EXPIRE_DAYS
373
374 if expire_days <= 0:
375 print(
376 f"❌ --expire-days must be a positive integer, got: {expire_days}",
377 file=sys.stderr,
378 )
379 raise SystemExit(ExitCode.USER_ERROR)
380
381 total_expired = 0
382 total_kept = 0
383 refs_processed: list[str] = []
384
385 if all_refs:
386 # Expire HEAD log + every branch log.
387 exp, kept = expire_reflog(repo_root, branch=None, expire_days=expire_days, dry_run=dry_run)
388 total_expired += exp
389 total_kept += kept
390 refs_processed.append("HEAD")
391
392 for br in list_reflog_refs(repo_root):
393 exp, kept = expire_reflog(repo_root, branch=br, expire_days=expire_days, dry_run=dry_run)
394 total_expired += exp
395 total_kept += kept
396 refs_processed.append(f"refs/heads/{br}")
397 else:
398 # Expire one log: either the named branch or HEAD.
399 exp, kept = expire_reflog(
400 repo_root,
401 branch=branch,
402 expire_days=expire_days,
403 dry_run=dry_run,
404 )
405 total_expired += exp
406 total_kept += kept
407 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
408 refs_processed.append(ref_label)
409
410 if json_out:
411 payload = _ExpireJson(
412 **make_envelope(elapsed),
413 expired=total_expired,
414 kept=total_kept,
415 dry_run=dry_run,
416 refs_processed=refs_processed,
417 )
418 print(json.dumps(payload))
419 return
420
421 if dry_run:
422 print(
423 f"(dry-run) Would expire {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
424 f"keep {total_kept}."
425 )
426 else:
427 print(
428 f"Expired {total_expired} entr{'y' if total_expired == 1 else 'ies'}, "
429 f"kept {total_kept}."
430 )
431
432
433 # ---------------------------------------------------------------------------
434 # delete subcommand
435 # ---------------------------------------------------------------------------
436
437 _AT_BRACE_PREFIX = "@{"
438 _AT_BRACE_SUFFIX = "}"
439
440
441 def _parse_at_index(raw: str) -> int:
442 """Parse ``@{N}`` into an integer index.
443
444 Raises ``ValueError`` for anything that doesn't match ``@{<digits>}``.
445 """
446 s = raw.strip()
447 if not (s.startswith(_AT_BRACE_PREFIX) and s.endswith(_AT_BRACE_SUFFIX)):
448 raise ValueError(f"Expected @{{N}} form, got: {s!r}")
449 inner = s[len(_AT_BRACE_PREFIX):-len(_AT_BRACE_SUFFIX)]
450 return int(inner) # raises ValueError for non-digits
451
452
453 def run_delete(args: argparse.Namespace) -> None:
454 """Delete one reflog entry by ``@{N}`` index, or all entries with ``--all``.
455
456 ``@{0}`` is the newest entry; ``@{N-1}`` is the oldest. The operation
457 is atomic: content is written to a temp file and renamed into place.
458 An empty log after deletion is removed rather than left as a zero-byte stub.
459
460 JSON schema::
461
462 {
463 "exit_code": 0,
464 "duration_ms": 1.1,
465 "deleted": 1,
466 "remaining": 5,
467 "ref": "HEAD"
468 }
469
470 Exit codes::
471
472 0 Success.
473 1 Invalid arguments or out-of-bounds index.
474 2 Not inside a Muse repository.
475 """
476 elapsed = start_timer()
477 json_out: bool = getattr(args, "json_out", False)
478 delete_all: bool = getattr(args, "delete_all", False)
479 branch: str | None = getattr(args, "branch", None)
480 raw_index: str | None = getattr(args, "index", None)
481
482 # Validate mutual exclusion.
483 if delete_all and raw_index is not None:
484 print(
485 "❌ --all and @{N} index are mutually exclusive — use one or the other.",
486 file=sys.stderr,
487 )
488 raise SystemExit(ExitCode.USER_ERROR)
489
490 if not delete_all and raw_index is None:
491 print(
492 "❌ Specify an entry to delete: `muse reflog delete @{N}` or `muse reflog delete --all`.",
493 file=sys.stderr,
494 )
495 raise SystemExit(ExitCode.USER_ERROR)
496
497 # Parse the index when given.
498 index: int | None = None
499 if raw_index is not None:
500 try:
501 index = _parse_at_index(raw_index)
502 except ValueError:
503 print(
504 f"❌ Invalid index {sanitize_display(raw_index)!r} — "
505 "expected @{N} where N is a non-negative integer.",
506 file=sys.stderr,
507 )
508 raise SystemExit(ExitCode.USER_ERROR)
509
510 # Validate branch name.
511 if branch is not None:
512 try:
513 validate_branch_name(branch)
514 except ValueError as exc:
515 print(
516 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
517 file=sys.stderr,
518 )
519 raise SystemExit(ExitCode.USER_ERROR)
520
521 repo_root = require_repo()
522 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
523
524 try:
525 deleted, remaining = delete_reflog_entry(repo_root, branch=branch, index=index)
526 except FileNotFoundError:
527 print(
528 f"❌ No reflog for {sanitize_display(ref_label)} — nothing to delete.",
529 file=sys.stderr,
530 )
531 raise SystemExit(ExitCode.USER_ERROR)
532 except IndexError as exc:
533 idx_val, total_val = exc.args[0], exc.args[1]
534 max_valid = total_val - 1
535 print(
536 f"❌ Index @{{{idx_val}}} is out of range — "
537 f"valid range: 0–{max_valid} ({total_val} entries).",
538 file=sys.stderr,
539 )
540 raise SystemExit(ExitCode.USER_ERROR)
541 except OSError as exc:
542 print(f"❌ Delete failed: {exc}", file=sys.stderr)
543 raise SystemExit(ExitCode.ERROR)
544
545 if json_out:
546 payload = _DeleteJson(
547 **make_envelope(elapsed),
548 deleted=deleted,
549 remaining=remaining,
550 ref=ref_label,
551 )
552 print(json.dumps(payload))
553 return
554
555 if delete_all:
556 print(f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} from {sanitize_display(ref_label)}.")
557 else:
558 print(
559 f"Deleted @{{{index}}} from {sanitize_display(ref_label)}. "
560 f"{remaining} entr{'y' if remaining == 1 else 'ies'} remaining."
561 )
562
563
564 # ---------------------------------------------------------------------------
565 # exists subcommand
566 # ---------------------------------------------------------------------------
567
568 def run_exists(args: argparse.Namespace) -> None:
569 """Fast existence check — does this ref have any reflog entries?
570
571 Exits 0 when entries are present, 1 when none (scriptable without --json).
572
573 JSON schema::
574
575 {
576 "exit_code": 0,
577 "duration_ms": 0.3,
578 "ref": "HEAD",
579 "exists": true,
580 "count": 7
581 }
582
583 Exit codes::
584
585 0 Entries exist.
586 1 No entries (not an error — the same pattern as ``test``/``grep -q``).
587 2 Not inside a Muse repository.
588 """
589 elapsed = start_timer()
590 json_out: bool = getattr(args, "json_out", False)
591 branch: str | None = getattr(args, "branch", None)
592
593 repo_root = require_repo()
594 ref_label = f"refs/heads/{branch}" if branch else "HEAD"
595
596 entries = read_reflog(repo_root, branch=branch, limit=10_000)
597 count = len(entries)
598 exists = count > 0
599
600 if json_out:
601 payload = _ExistsJson(
602 **make_envelope(elapsed, exit_code=0 if exists else 1),
603 exists=exists,
604 count=count,
605 ref=ref_label,
606 )
607 print(json.dumps(payload))
608 else:
609 safe_ref = sanitize_display(ref_label)
610 if exists:
611 print(f"{safe_ref} has {count} reflog entr{'y' if count == 1 else 'ies'}.")
612 else:
613 print(f"No reflog entries for {safe_ref}.")
614
615 if not exists:
616 raise SystemExit(1)
617
618
619 # ---------------------------------------------------------------------------
620 # Command entry point
621 # ---------------------------------------------------------------------------
622
623 def run(args: argparse.Namespace) -> None:
624 """Show the history of HEAD and branch-ref movements.
625
626 Every time HEAD or a branch ref moves — commit, checkout, merge, reset,
627 cherry-pick, shelf pop — Muse appends an entry to the reflog. Use this
628 command to find lost commits and undo accidental resets.
629
630 The ``--limit`` cap applies *after* all filters, so ``--limit 10
631 --operation commit`` always returns exactly 10 commit events (or fewer
632 if the history is shorter).
633
634 Agent quickstart::
635
636 muse reflog --json
637 muse reflog --branch dev --json
638 muse reflog --operation commit --limit 5 --json
639 muse reflog --all --json
640
641 JSON fields::
642
643 ref str full ref name (e.g. "refs/heads/main" or "HEAD")
644 total int number of entries after all filters applied
645 limit int display cap that was requested
646 entries list [{index, new_id, old_id, timestamp, operation, author}]
647
648 JSON fields (--all mode)::
649
650 refs list full ref names that have a reflog
651 count int number of refs
652
653 Exit codes::
654
655 0 Success.
656 1 Invalid arguments (bad branch, bad format, bad date).
657 2 Not inside a Muse repository.
658 """
659 elapsed = start_timer()
660 branch: str | None = args.branch
661 limit: int = clamp_int(args.limit, 1, 100_000, "limit")
662 all_refs: bool = args.all_refs
663 json_out: bool = args.json_out
664 operation_filter: str | None = args.operation_filter
665 author_filter: str | None = args.author_filter
666 since_str: str | None = args.since
667 until_str: str | None = args.until
668
669 # Parse date filters before repo access so bad dates fail fast.
670 since_dt: datetime.datetime | None = (
671 _parse_date(since_str, "--since") if since_str else None
672 )
673 until_dt: datetime.datetime | None = (
674 _parse_date(until_str, "--until") if until_str else None
675 )
676 if since_dt and until_dt and since_dt > until_dt:
677 print("❌ --since must not be after --until.", file=sys.stderr)
678 raise SystemExit(ExitCode.USER_ERROR)
679
680 repo_root = require_repo()
681
682 # ── --all mode ────────────────────────────────────────────────────────────
683
684 if all_refs:
685 refs = list_reflog_refs(repo_root)
686 full_refs = [f"refs/heads/{r}" for r in refs]
687 if json_out:
688 payload = _ReflogAllJson(
689 **make_envelope(elapsed),
690 refs=full_refs,
691 count=len(full_refs),
692 )
693 print(json.dumps(payload))
694 else:
695 if not refs:
696 print("No reflog entries found.")
697 return
698 print("Refs with reflog entries:")
699 for ref in refs:
700 print(f" refs/heads/{sanitize_display(ref)}")
701 return
702
703 # ── Branch validation ─────────────────────────────────────────────────────
704
705 if branch is not None:
706 try:
707 validate_branch_name(branch)
708 except ValueError as exc:
709 print(
710 f"❌ Invalid branch name: {sanitize_display(str(exc))}",
711 file=sys.stderr,
712 )
713 raise SystemExit(ExitCode.USER_ERROR)
714
715 # ── Read all entries (limit is applied after filtering) ───────────────────
716
717 # Read a generous cap so post-filter limit can be satisfied.
718 raw_limit = min(limit * 50, 100_000)
719 entries = read_reflog(repo_root, branch=branch, limit=raw_limit)
720
721 # ── Apply filters ─────────────────────────────────────────────────────────
722
723 filtered: list[ReflogEntry] = entries
724
725 if operation_filter is not None:
726 needle = operation_filter.lower()
727 filtered = [e for e in filtered if needle in e.operation.lower()]
728
729 if author_filter is not None:
730 needle_a = author_filter.lower()
731 filtered = [e for e in filtered if needle_a in (e.author or "").lower()]
732
733 if since_dt is not None:
734 filtered = [e for e in filtered if e.timestamp >= since_dt]
735
736 if until_dt is not None:
737 # until is inclusive: entries on or before the end of until_dt day
738 until_end = until_dt + datetime.timedelta(days=1)
739 filtered = [e for e in filtered if e.timestamp < until_end]
740
741 # Apply display limit after all filters.
742 displayed = filtered[:limit]
743
744 # ── Output ────────────────────────────────────────────────────────────────
745
746 ref_name = f"refs/heads/{branch}" if branch else "HEAD"
747
748 if json_out:
749 json_entries: list[_ReflogEntryJson] = [
750 _ReflogEntryJson(
751 index=idx,
752 new_id=long_id(e.new_id),
753 old_id=long_id(e.old_id),
754 timestamp=e.timestamp.isoformat(),
755 operation=e.operation,
756 author=e.author,
757 )
758 for idx, e in enumerate(displayed)
759 ]
760 payload_result = _ReflogResultJson(
761 **make_envelope(elapsed),
762 ref=ref_name,
763 total=len(filtered),
764 limit=limit,
765 entries=json_entries,
766 )
767 print(json.dumps(payload_result))
768 return
769
770 safe_label = sanitize_display(ref_name)
771 if not displayed:
772 if filtered:
773 print(f"No reflog entries for {safe_label} match the active filters.")
774 else:
775 print(f"No reflog entries for {safe_label}.")
776 return
777
778 print(f"Reflog for {safe_label} (newest first)\n")
779 for idx, entry in enumerate(displayed):
780 print(_fmt_entry(idx, entry))
781
782 if len(filtered) > limit:
783 remaining = len(filtered) - limit
784 print(f"\n … {remaining} older entry/entries — increase --limit to see more.")
File History 1 commit
sha256:649be9ce77a127cd847c4f798d60104dab7fe830cbc928762fafe3deadf38839 chore: point muse-git-backup.sh's MUSE_COPY at the fresh 'm… Sonnet 5 patch 13 days ago