reflog.py
python
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