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