"""``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, stash 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, "entries": [ { "index": 0, "new_id": "<64-char hex>", "old_id": "<64-char hex or zeros>", "timestamp": "2026-03-16T12:00:00+00:00", "operation": "commit: add verse", "author": "alice" } ] } Exit codes ---------- - 0 — success - 1 — invalid arguments (bad branch, bad format, bad date) - 2 — not inside a Muse repository """ from __future__ import annotations import argparse import datetime import json import logging import sys from typing import TYPE_CHECKING, TypedDict from muse.core.errors import ExitCode from muse.core.reflog import ReflogEntry, list_reflog_refs, read_reflog from muse.core.repo import require_repo from muse.core.validation import clamp_int, sanitize_display, validate_branch_name if TYPE_CHECKING: pass 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(TypedDict): """Top-level JSON object returned by ``muse reflog --json``.""" ref: str total: int limit: int entries: list[_ReflogEntryJson] class _ReflogAllJson(TypedDict): """JSON object returned by ``muse reflog --all --json``.""" refs: list[str] count: int # --------------------------------------------------------------------------- # 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. """ new_short = sanitize_display(entry.new_id[:short]) old_short = ( "initial" if entry.old_id == "0" * 64 else sanitize_display(entry.old_id[:short]) ) 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}:<3}} {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.""" 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", "-n", 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( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # 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, stash 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). Agents should pass ``--json`` to receive a stable ``_ReflogResultJson`` object with metadata (ref, total, limit, entries). """ branch: str | None = args.branch limit: int = clamp_int(args.limit, 1, 100_000, "limit") all_refs: bool = args.all_refs fmt: str = args.fmt 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 if fmt not in ("text", "json"): print( f"❌ Unknown --format {sanitize_display(fmt)!r}. Choose text or json.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) # 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 fmt == "json": payload = _ReflogAllJson(refs=full_refs, count=len(full_refs)) print(json.dumps(payload, indent=2)) 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 fmt == "json": json_entries: list[_ReflogEntryJson] = [ _ReflogEntryJson( index=idx, new_id=e.new_id, old_id=e.old_id, timestamp=e.timestamp.isoformat(), operation=e.operation, author=e.author, ) for idx, e in enumerate(displayed) ] payload_result = _ReflogResultJson( ref=ref_name, total=len(filtered), limit=limit, entries=json_entries, ) print(json.dumps(payload_result, indent=2)) 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.")