"""``muse reflog`` — inspect the history of HEAD and branch movements. The reflog is a chronological journal of every time a ref moved: commits, checkouts, merges, resets, cherry-picks, shelf pops. It is your safety net when you need to undo an operation that moved HEAD. Usage:: muse reflog # HEAD reflog, last 20 entries muse reflog --branch dev # dev branch reflog muse reflog --limit 100 # show more entries muse reflog --all # list all refs that have a reflog muse reflog --operation commit # only commit events muse reflog --author alice # only events by alice muse reflog --since 2026-01-01 # entries on or after date muse reflog --until 2026-03-01 # entries on or before date muse reflog --json # machine-readable JSON Each text row shows:: @{N} () The ``@{N}`` syntax mirrors Git so scripts that already understand Git reflogs need no translation. Security model -------------- - Branch names are validated via ``validate_branch_name`` before being used to construct a filesystem path — prevents path traversal. - All user-controlled values (operation, author, commit IDs, branch names) are passed through ``sanitize_display()`` before terminal output — prevents ANSI injection from stored reflog data. - Date filter values are validated as ``YYYY-MM-DD`` before use. - Error messages go to **stderr**; **stdout** carries only data. Agent UX -------- Pass ``--json`` for a stable ``_ReflogResultJson`` object on stdout. All fields are always present. Apply ``--operation``, ``--author``, ``--since``, ``--until`` to narrow without changing the JSON schema. JSON schema (``--json``):: { "ref": "refs/heads/main", "total": 3, "limit": 20, "duration_ms": 4.123, "exit_code": 0, "entries": [ { "index": 0, "new_id": "sha256:<64-hex>", "old_id": "sha256:<64-hex or 000…>", "timestamp": "2026-03-16T12:00:00+00:00", "operation": "commit: add verse", "author": "alice" } ] } ``new_id`` and ``old_id`` are always ``sha256:<64-hex>`` canonical form — consistent with ``muse read-commit`` and all other ID-bearing commands. The on-disk reflog stores bare hex; the JSON layer normalises them. Exit codes ---------- - 0 — success - 1 — invalid arguments (bad branch, bad format, bad date) - 2 — not inside a Muse repository """ import argparse import datetime import json import logging import sys import time from typing import TypedDict from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.errors import ExitCode from muse.core.reflog import ReflogEntry, delete_reflog_entry, expire_reflog, list_reflog_refs, read_reflog from muse.core.repo import require_repo from muse.core.validation import clamp_int, sanitize_display, validate_branch_name from muse.core.types import NULL_COMMIT_ID, long_id, short_id from muse.core.timing import start_timer logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # JSON TypedDicts — stable machine-readable output schemas # --------------------------------------------------------------------------- class _ReflogEntryJson(TypedDict): """One entry in the JSON reflog output.""" index: int new_id: str old_id: str timestamp: str operation: str author: str class _ReflogResultJson(EnvelopeJson): """Top-level JSON object returned by ``muse reflog --json``. All fields are always present. Envelope fields (``duration_ms``, ``exit_code``, etc.) are command metadata — not reflog data — and are always emitted regardless of filters applied. ``new_id`` and ``old_id`` in each entry are ``sha256:<64-hex>`` canonical form, matching the Muse ID convention everywhere else. """ ref: str total: int limit: int entries: list[_ReflogEntryJson] class _ReflogAllJson(EnvelopeJson): """JSON object returned by ``muse reflog --all --json``.""" refs: list[str] count: int class _ExpireJson(EnvelopeJson): """JSON object returned by ``muse reflog expire --json``.""" expired: int kept: int dry_run: bool refs_processed: list[str] class _DeleteJson(EnvelopeJson): """JSON object returned by ``muse reflog delete --json``.""" deleted: int remaining: int ref: str class _ExistsJson(EnvelopeJson): """JSON object returned by ``muse reflog exists --json``.""" exists: bool count: int ref: str # --------------------------------------------------------------------------- # Formatting helpers # --------------------------------------------------------------------------- def _fmt_entry(idx: int, entry: ReflogEntry, short: int = 12) -> str: """Format one reflog entry for terminal display. All user-controlled fields (new_id, old_id, author, operation) are passed through ``sanitize_display()`` to prevent ANSI injection. Short IDs are rendered as ``sha256:<12-hex>`` (19 chars) for consistency with all other Muse commands. """ new_short = sanitize_display(short_id(entry.new_id)) old_short = ( "initial" if entry.old_id == NULL_COMMIT_ID else sanitize_display(short_id(entry.old_id)) ) when = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") safe_op = sanitize_display(entry.operation) safe_author = sanitize_display(entry.author or "") author_col = f" {safe_author}" if safe_author else "" return f"@{{{idx}}} {new_short} ({old_short}) {when}{author_col} {safe_op}" def _parse_date(value: str, flag: str) -> datetime.datetime: """Parse *value* as ``YYYY-MM-DD`` into a UTC-aware datetime. Raises SystemExit(USER_ERROR) for invalid format. """ try: d = datetime.date.fromisoformat(value) except ValueError: print( f"❌ Invalid date for {flag}: {sanitize_display(value)!r} — " "expected YYYY-MM-DD.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) return datetime.datetime(d.year, d.month, d.day, tzinfo=datetime.timezone.utc) # --------------------------------------------------------------------------- # Argument parser registration # --------------------------------------------------------------------------- def register( subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", ) -> None: """Register the ``reflog`` subcommand tree.""" parser = subparsers.add_parser( "reflog", help="Show the history of HEAD and branch-ref movements.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--branch", "-b", default=None, help="Branch to show reflog for (default: HEAD).", ) parser.add_argument( "--limit", type=int, default=20, help="Maximum number of entries to show (after filters, default: 20).", ) parser.add_argument( "--all", action="store_true", dest="all_refs", help="List all refs that have a reflog.", ) parser.add_argument( "--operation", default=None, metavar="PATTERN", dest="operation_filter", help="Filter to entries whose operation contains PATTERN (case-insensitive).", ) parser.add_argument( "--author", default=None, metavar="PATTERN", dest="author_filter", help="Filter to entries whose author contains PATTERN (case-insensitive).", ) parser.add_argument( "--since", default=None, metavar="YYYY-MM-DD", dest="since", help="Show only entries on or after this date.", ) parser.add_argument( "--until", default=None, metavar="YYYY-MM-DD", dest="until", help="Show only entries on or before this date.", ) parser.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON instead of human text.", ) # ── subcommands ─────────────────────────────────────────────────────────── subs = parser.add_subparsers(dest="reflog_subcommand", metavar="SUBCOMMAND") # expire expire_p = subs.add_parser( "expire", help="Prune reflog entries older than a configurable TTL.", formatter_class=argparse.RawDescriptionHelpFormatter, ) expire_p.add_argument( "--expire-days", type=int, default=None, dest="expire_days", help="Expire entries older than this many days (default: 90, or reflog.expire-days config).", ) expire_p.add_argument( "--branch", "-b", default=None, help="Expire only the named branch log (default: HEAD log only).", ) expire_p.add_argument( "--all", action="store_true", dest="all_refs", help="Expire HEAD log and all branch logs.", ) expire_p.add_argument( "--dry-run", action="store_true", dest="dry_run", help="Report what would be removed without writing anything.", ) expire_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.", ) expire_p.set_defaults(func=run_expire) # delete delete_p = subs.add_parser( "delete", help="Delete one reflog entry by @{N} index, or all entries with --all.", formatter_class=argparse.RawDescriptionHelpFormatter, ) delete_p.add_argument( "index", nargs="?", default=None, metavar="@{N}", help="Entry index to delete (0 = newest). Omit when using --all.", ) delete_p.add_argument( "--all", action="store_true", dest="delete_all", help="Delete all entries from the log (and remove the log file).", ) delete_p.add_argument( "--branch", "-b", default=None, help="Target the named branch log instead of HEAD.", ) delete_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.", ) delete_p.set_defaults(func=run_delete) # exists exists_p = subs.add_parser( "exists", help="Exit 0 if a reflog has entries; exit 1 if none.", formatter_class=argparse.RawDescriptionHelpFormatter, ) exists_p.add_argument( "--branch", "-b", default=None, help="Check the named branch log instead of HEAD.", ) exists_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON.", ) exists_p.set_defaults(func=run_exists) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # expire subcommand # --------------------------------------------------------------------------- _DEFAULT_EXPIRE_DAYS = 90 def run_expire(args: argparse.Namespace) -> None: """Prune reflog entries older than a configured TTL. Reads ``reflog.expire-days`` from ``muse config`` when ``--expire-days`` is not supplied. Falls back to 90 days when neither is set. The write is atomic: entries are filtered to a temp file and swapped in with ``os.replace``. An empty log after expiry is deleted rather than left as a zero-byte stub. JSON schema:: { "exit_code": 0, "duration_ms": 4.2, "expired": 47, "kept": 12, "dry_run": false, "refs_processed": ["HEAD", "refs/heads/main", "refs/heads/dev"] } Exit codes:: 0 Success (including when nothing expired). 1 Invalid arguments. 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = getattr(args, "json_out", False) dry_run: bool = getattr(args, "dry_run", False) all_refs: bool = getattr(args, "all_refs", False) branch: str | None = getattr(args, "branch", None) expire_days: int | None = getattr(args, "expire_days", None) # --all and --branch together are ambiguous. if all_refs and branch is not None: print( "❌ --all and --branch are mutually exclusive — " "use one or the other.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) repo_root = require_repo() # Resolve expire_days: flag > config > default. if expire_days is None: from muse.cli.config import get_config_value cfg_val = get_config_value("reflog.expire-days", repo_root) if cfg_val is not None: try: expire_days = int(cfg_val) except ValueError: expire_days = _DEFAULT_EXPIRE_DAYS else: expire_days = _DEFAULT_EXPIRE_DAYS if expire_days <= 0: print( f"❌ --expire-days must be a positive integer, got: {expire_days}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) total_expired = 0 total_kept = 0 refs_processed: list[str] = [] if all_refs: # Expire HEAD log + every branch log. exp, kept = expire_reflog(repo_root, branch=None, expire_days=expire_days, dry_run=dry_run) total_expired += exp total_kept += kept refs_processed.append("HEAD") for br in list_reflog_refs(repo_root): exp, kept = expire_reflog(repo_root, branch=br, expire_days=expire_days, dry_run=dry_run) total_expired += exp total_kept += kept refs_processed.append(f"refs/heads/{br}") else: # Expire one log: either the named branch or HEAD. exp, kept = expire_reflog( repo_root, branch=branch, expire_days=expire_days, dry_run=dry_run, ) total_expired += exp total_kept += kept ref_label = f"refs/heads/{branch}" if branch else "HEAD" refs_processed.append(ref_label) if json_out: payload = _ExpireJson( **make_envelope(elapsed), expired=total_expired, kept=total_kept, dry_run=dry_run, refs_processed=refs_processed, ) print(json.dumps(payload)) return if dry_run: print( f"(dry-run) Would expire {total_expired} entr{'y' if total_expired == 1 else 'ies'}, " f"keep {total_kept}." ) else: print( f"Expired {total_expired} entr{'y' if total_expired == 1 else 'ies'}, " f"kept {total_kept}." ) # --------------------------------------------------------------------------- # delete subcommand # --------------------------------------------------------------------------- _AT_BRACE_PREFIX = "@{" _AT_BRACE_SUFFIX = "}" def _parse_at_index(raw: str) -> int: """Parse ``@{N}`` into an integer index. Raises ``ValueError`` for anything that doesn't match ``@{}``. """ s = raw.strip() if not (s.startswith(_AT_BRACE_PREFIX) and s.endswith(_AT_BRACE_SUFFIX)): raise ValueError(f"Expected @{{N}} form, got: {s!r}") inner = s[len(_AT_BRACE_PREFIX):-len(_AT_BRACE_SUFFIX)] return int(inner) # raises ValueError for non-digits def run_delete(args: argparse.Namespace) -> None: """Delete one reflog entry by ``@{N}`` index, or all entries with ``--all``. ``@{0}`` is the newest entry; ``@{N-1}`` is the oldest. The operation is atomic: content is written to a temp file and renamed into place. An empty log after deletion is removed rather than left as a zero-byte stub. JSON schema:: { "exit_code": 0, "duration_ms": 1.1, "deleted": 1, "remaining": 5, "ref": "HEAD" } Exit codes:: 0 Success. 1 Invalid arguments or out-of-bounds index. 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = getattr(args, "json_out", False) delete_all: bool = getattr(args, "delete_all", False) branch: str | None = getattr(args, "branch", None) raw_index: str | None = getattr(args, "index", None) # Validate mutual exclusion. if delete_all and raw_index is not None: print( "❌ --all and @{N} index are mutually exclusive — use one or the other.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if not delete_all and raw_index is None: print( "❌ Specify an entry to delete: `muse reflog delete @{N}` or `muse reflog delete --all`.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Parse the index when given. index: int | None = None if raw_index is not None: try: index = _parse_at_index(raw_index) except ValueError: print( f"❌ Invalid index {sanitize_display(raw_index)!r} — " "expected @{N} where N is a non-negative integer.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # Validate branch name. if branch is not None: try: validate_branch_name(branch) except ValueError as exc: print( f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) repo_root = require_repo() ref_label = f"refs/heads/{branch}" if branch else "HEAD" try: deleted, remaining = delete_reflog_entry(repo_root, branch=branch, index=index) except FileNotFoundError: print( f"❌ No reflog for {sanitize_display(ref_label)} — nothing to delete.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) except IndexError as exc: idx_val, total_val = exc.args[0], exc.args[1] max_valid = total_val - 1 print( f"❌ Index @{{{idx_val}}} is out of range — " f"valid range: 0–{max_valid} ({total_val} entries).", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) except OSError as exc: print(f"❌ Delete failed: {exc}", file=sys.stderr) raise SystemExit(ExitCode.ERROR) if json_out: payload = _DeleteJson( **make_envelope(elapsed), deleted=deleted, remaining=remaining, ref=ref_label, ) print(json.dumps(payload)) return if delete_all: print(f"Deleted {deleted} entr{'y' if deleted == 1 else 'ies'} from {sanitize_display(ref_label)}.") else: print( f"Deleted @{{{index}}} from {sanitize_display(ref_label)}. " f"{remaining} entr{'y' if remaining == 1 else 'ies'} remaining." ) # --------------------------------------------------------------------------- # exists subcommand # --------------------------------------------------------------------------- def run_exists(args: argparse.Namespace) -> None: """Fast existence check — does this ref have any reflog entries? Exits 0 when entries are present, 1 when none (scriptable without --json). JSON schema:: { "exit_code": 0, "duration_ms": 0.3, "ref": "HEAD", "exists": true, "count": 7 } Exit codes:: 0 Entries exist. 1 No entries (not an error — the same pattern as ``test``/``grep -q``). 2 Not inside a Muse repository. """ elapsed = start_timer() json_out: bool = getattr(args, "json_out", False) branch: str | None = getattr(args, "branch", None) repo_root = require_repo() ref_label = f"refs/heads/{branch}" if branch else "HEAD" entries = read_reflog(repo_root, branch=branch, limit=10_000) count = len(entries) exists = count > 0 if json_out: payload = _ExistsJson( **make_envelope(elapsed, exit_code=0 if exists else 1), exists=exists, count=count, ref=ref_label, ) print(json.dumps(payload)) else: safe_ref = sanitize_display(ref_label) if exists: print(f"{safe_ref} has {count} reflog entr{'y' if count == 1 else 'ies'}.") else: print(f"No reflog entries for {safe_ref}.") if not exists: raise SystemExit(1) # --------------------------------------------------------------------------- # Command entry point # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> None: """Show the history of HEAD and branch-ref movements. Every time HEAD or a branch ref moves — commit, checkout, merge, reset, cherry-pick, shelf pop — Muse appends an entry to the reflog. Use this command to find lost commits and undo accidental resets. The ``--limit`` cap applies *after* all filters, so ``--limit 10 --operation commit`` always returns exactly 10 commit events (or fewer if the history is shorter). Agent quickstart:: muse reflog --json muse reflog --branch dev --json muse reflog --operation commit --limit 5 --json muse reflog --all --json JSON fields:: ref str full ref name (e.g. "refs/heads/main" or "HEAD") total int number of entries after all filters applied limit int display cap that was requested entries list [{index, new_id, old_id, timestamp, operation, author}] JSON fields (--all mode):: refs list full ref names that have a reflog count int number of refs Exit codes:: 0 Success. 1 Invalid arguments (bad branch, bad format, bad date). 2 Not inside a Muse repository. """ elapsed = start_timer() branch: str | None = args.branch limit: int = clamp_int(args.limit, 1, 100_000, "limit") all_refs: bool = args.all_refs json_out: bool = args.json_out operation_filter: str | None = args.operation_filter author_filter: str | None = args.author_filter since_str: str | None = args.since until_str: str | None = args.until # Parse date filters before repo access so bad dates fail fast. since_dt: datetime.datetime | None = ( _parse_date(since_str, "--since") if since_str else None ) until_dt: datetime.datetime | None = ( _parse_date(until_str, "--until") if until_str else None ) if since_dt and until_dt and since_dt > until_dt: print("❌ --since must not be after --until.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) repo_root = require_repo() # ── --all mode ──────────────────────────────────────────────────────────── if all_refs: refs = list_reflog_refs(repo_root) full_refs = [f"refs/heads/{r}" for r in refs] if json_out: payload = _ReflogAllJson( **make_envelope(elapsed), refs=full_refs, count=len(full_refs), ) print(json.dumps(payload)) else: if not refs: print("No reflog entries found.") return print("Refs with reflog entries:") for ref in refs: print(f" refs/heads/{sanitize_display(ref)}") return # ── Branch validation ───────────────────────────────────────────────────── if branch is not None: try: validate_branch_name(branch) except ValueError as exc: print( f"❌ Invalid branch name: {sanitize_display(str(exc))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # ── Read all entries (limit is applied after filtering) ─────────────────── # Read a generous cap so post-filter limit can be satisfied. raw_limit = min(limit * 50, 100_000) entries = read_reflog(repo_root, branch=branch, limit=raw_limit) # ── Apply filters ───────────────────────────────────────────────────────── filtered: list[ReflogEntry] = entries if operation_filter is not None: needle = operation_filter.lower() filtered = [e for e in filtered if needle in e.operation.lower()] if author_filter is not None: needle_a = author_filter.lower() filtered = [e for e in filtered if needle_a in (e.author or "").lower()] if since_dt is not None: filtered = [e for e in filtered if e.timestamp >= since_dt] if until_dt is not None: # until is inclusive: entries on or before the end of until_dt day until_end = until_dt + datetime.timedelta(days=1) filtered = [e for e in filtered if e.timestamp < until_end] # Apply display limit after all filters. displayed = filtered[:limit] # ── Output ──────────────────────────────────────────────────────────────── ref_name = f"refs/heads/{branch}" if branch else "HEAD" if json_out: json_entries: list[_ReflogEntryJson] = [ _ReflogEntryJson( index=idx, new_id=long_id(e.new_id), old_id=long_id(e.old_id), timestamp=e.timestamp.isoformat(), operation=e.operation, author=e.author, ) for idx, e in enumerate(displayed) ] payload_result = _ReflogResultJson( **make_envelope(elapsed), ref=ref_name, total=len(filtered), limit=limit, entries=json_entries, ) print(json.dumps(payload_result)) return safe_label = sanitize_display(ref_name) if not displayed: if filtered: print(f"No reflog entries for {safe_label} match the active filters.") else: print(f"No reflog entries for {safe_label}.") return print(f"Reflog for {safe_label} (newest first)\n") for idx, entry in enumerate(displayed): print(_fmt_entry(idx, entry)) if len(filtered) > limit: remaining = len(filtered) - limit print(f"\n … {remaining} older entry/entries — increase --limit to see more.")