# `muse reflog` — Complete Coverage, Lifecycle Management, and `@{N}` Resolution ## Background The reflog foundation is solid: `muse/core/reflog.py` appends entries for commit, checkout, branch-create, merge, reset, rebase, cherry-pick, revert, and switch. The `muse reflog` CLI reads those entries with filters (operation, author, since, until). The storage format mirrors Git's so cross-tool reading works without translation. Three gaps prevent reflog from fulfilling its core promise — **the complete local undo safety net**: **1. Coverage gaps.** `muse pull` and `muse shelf pop`/`muse shelf apply` move branch pointers but write no reflog entry. Any pull that advances HEAD is invisible to `muse reflog`. **2. No lifecycle management.** Reflog files grow forever. There is no `muse reflog expire`, no GC integration, and no per-entry deletion. In long-lived repos with active rebase workflows the `.muse/logs/` tree is unbounded. **3. `@{N}` is display-only.** `muse reflog` outputs `@{0}`, `@{1}`, etc. but these indices are not resolvable as commit refs. `muse reset @{1}` fails. The safety-net workflow described in the docs — "find the bad reset in reflog, restore with `muse reset`" — requires manual SHA copy-paste, breaking the UX promise entirely. ## Goal After this issue is complete: - Every operation that moves a ref writes a reflog entry — no silent pointer moves. - Reflog files are bounded by time-based expiry with configurable TTL, integrated with `muse gc`. - `@{N}` is a first-class commit ref usable anywhere a SHA-256 is accepted. - `muse reflog expire`, `muse reflog delete`, and `muse reflog exists` exist as plumbing subcommands. ## Phases ### Phase 1 — Close coverage gaps (`pull` and `shelf`) Wire `append_reflog` into `muse pull` and `muse shelf pop`/`muse shelf apply`. These are the most common agent operations where the ref moves silently. A pull that advances 30 commits leaves no reflog trace — the pre-pull HEAD is simply gone. **Deliverables** - [x] `RL_01` — `muse pull` writes `pull: fetched /` after the branch pointer advances; entry includes old and new commit IDs - [x] `RL_02` — `muse shelf pop` writes `shelf-pop: restore from ` entry when HEAD moves - [x] `RL_03` — `muse shelf apply` writes `shelf-apply: restored from ` (keeps the shelf entry, no pop) when HEAD moves - [x] `RL_04` — Reflog writes in all three operations are non-fatal: a write failure logs a warning but never aborts the operation - [x] `RL_05` — Integration test: two-repo pull scenario confirms HEAD log gains exactly one entry per pull; shelf-pop confirms entry appears **Test file:** `tests/test_cmd_reflog_coverage.py` --- ### Phase 2 — `muse reflog expire` subcommand Add `muse reflog expire` to prune entries older than a configurable TTL. **Expiry semantics** - Default TTL: 90 days (configurable via `muse config reflog.expire-days`) - Entries referencing unreachable commits expire immediately regardless of age when `--reachable-only` is NOT set - Atomic write: tmp file + rename — never leaves a partial log - Empty log after expiry: file is removed, not left as a zero-byte stub **CLI** ``` muse reflog expire [--expire-days N] [--branch ] [--all] [--dry-run] [--json] ``` **JSON schema** ```json { "exit_code": 0, "duration_ms": 4.2, "expired": 47, "kept": 12, "dry_run": false, "refs_processed": ["refs/heads/main", "refs/heads/dev"] } ``` **Deliverables** - [x] `RL_06` — `muse reflog expire --expire-days 90 --json` removes entries older than 90 days from HEAD log; JSON reports `expired` count - [x] `RL_07` — `muse reflog expire --all --json` applies to all branch logs; JSON lists all `refs_processed` - [x] `RL_08` — `muse reflog expire --dry-run --json` reports what would be removed with `"dry_run": true`; writes nothing - [x] `RL_09` — `muse config reflog.expire-days N` sets the default TTL; subsequent `muse reflog expire` with no `--expire-days` uses it - [x] `RL_10` — Expire is atomic: implementation uses write-to-tmp + `os.replace` — verified by inspecting that no partial file exists if the process is killed mid-write (mock-kill test) - [x] `RL_11` — Integration test: 100-entry log with 50 entries older than threshold → exactly 50 removed, 50 kept; verified by re-reading the log - [x] `RL_12` — Edge case: all entries expire → log file is removed (not a zero-byte file); `muse reflog --json` returns `entries: []` cleanly **Test file:** `tests/test_cmd_reflog_expire.py` --- ### Phase 3 — `muse reflog delete` subcommand Delete individual entries by `@{N}` index or a bulk `--all` wipe. **CLI** ``` muse reflog delete @{N} [--branch ] [--json] muse reflog delete --all [--branch ] [--json] ``` **JSON schema** ```json { "exit_code": 0, "duration_ms": 1.1, "deleted": 1, "remaining": 5, "ref": "HEAD" } ``` **Deliverables** - [x] `RL_13` — `muse reflog delete @{0} --json` removes the newest entry; `deleted: 1`, `remaining` equals prior count minus one - [x] `RL_14` — `muse reflog delete --all --json` removes all entries; log file is deleted; `deleted` equals prior entry count - [x] `RL_15` — Out-of-bounds index exits `USER_ERROR` with a message that includes the valid range (e.g. "valid range: 0–4") - [x] `RL_16` — Delete is atomic (write-tmp + rename), same guarantee as expire - [x] `RL_17` — Integration test: 3-entry log → delete `@{1}` → 2 entries remain, and they are the correct entries (newest and oldest, middle removed) **Test file:** `tests/test_cmd_reflog_delete.py` --- ### Phase 4 — GC integration `muse gc` prunes stale reflog entries as part of its standard cleanup pass. **Deliverables** - [x] `RL_18` — `muse gc --json` prunes reflog entries older than `reflog.expire-days` (default 90); JSON output gains a `reflog_expired` integer field - [x] `RL_19` — `muse gc --dry-run --json` reports `reflog_expired` count alongside object counts; writes nothing - [x] `RL_20` — Integration test: repo with entries older than 90 days → `muse gc` → entries gone; verify by reading the log directly **Test file:** `tests/test_cmd_gc_reflog.py` --- ### Phase 5 — `@{N}` ref resolution Make `@{N}` a first-class ref specifier that resolves to a SHA-256 anywhere a commit ref is accepted. **Resolution rules** | Syntax | Resolves from | |--------|--------------| | `@{N}` | HEAD reflog, index N (0 = newest) | | `dev@{N}` | dev branch reflog, index N | | `refs/heads/dev@{N}` | same, full ref form | Out-of-range index → `USER_ERROR`, not silent null. **Resolution layer:** `muse/core/rev_parse.py` (or equivalent ref-resolution module) gains a `resolve_reflog_ref(spec, repo_root) -> str` function called before all other ref lookups. **Commands that must accept `@{N}`** | Command | Why | |---------|-----| | `muse rev-parse @{N}` | Foundation — all others delegate here | | `muse reset @{N}` | Primary safety-net use case | | `muse read @{N}` | Inspect the state at that point in history | | `muse diff @{N} HEAD` | Show what changed since that state | | `muse checkout @{N}` | Detached-HEAD recovery | **Deliverables** - [x] `RL_21` — `muse rev-parse @{0} --json` resolves to the commit ID at HEAD reflog index 0 (newest) - [x] `RL_22` — `muse reset @{1} --json` resets HEAD to reflog index 1; subsequent `muse read --json` confirms the correct commit - [x] `RL_23` — `muse read @{2} --json` reads the commit at HEAD reflog index 2 - [x] `RL_24` — `dev@{0}` resolves to the dev branch reflog's newest entry commit ID - [x] `RL_25` — Out-of-range index exits `USER_ERROR` with message stating the valid range - [x] `RL_26` — `muse diff @{1} HEAD --json` shows the diff between reflog state 1 and HEAD; `added`/`modified`/`deleted` fields are correct - [x] `RL_27` — End-to-end recovery test: commit A → commit B → `muse reset @{1}` → `muse read --json` shows commit A (the pre-reset state) **Test file:** `tests/test_reflog_ref_resolution.py` --- ### Phase 6 — `muse reflog exists` subcommand Fast scriptable existence check — does this ref have any reflog entries? **CLI** ``` muse reflog exists [--branch ] [--json] ``` Exit 0 if entries exist; exit 1 if none. **JSON schema** ```json { "exit_code": 0, "duration_ms": 0.3, "ref": "HEAD", "exists": true, "count": 7 } ``` **Deliverables** - [x] `RL_28` — `muse reflog exists --json` returns `{"exists": true, "count": N}` when HEAD log has entries; exit 0 - [x] `RL_29` — `muse reflog exists --branch dev --json` returns `{"exists": false, "count": 0}` for a branch with no log file; exit 1 - [x] `RL_30` — Exit code 0 for exists, 1 for does-not-exist — scriptable without `--json` **Test file:** `tests/test_cmd_reflog_exists.py` --- ## Acceptance Criteria - `muse pull` leaves a reflog trace every time a branch pointer advances. - `muse shelf pop` and `muse shelf apply` leave a reflog trace when HEAD moves. - `muse gc` prunes entries older than `reflog.expire-days` (default 90 days) and reports the count. - `muse reset @{1}` works end-to-end: commit → bad reset → `muse reset @{1}` → back at original commit. - `muse reflog expire --dry-run` reports entries that would be removed without writing anything. - `muse reflog delete @{N}` removes exactly that entry, atomically. - All 30 test IDs (`RL_01`–`RL_30`) are green. - `muse code test --json` for changed files reports no regressions. ## Out of Scope - Remote reflog sharing — reflogs are always local-only, never pushed. - `--format` options beyond the existing text/JSON modes. - Relative time syntax (`@{1.hour.ago}`) — use `--since`/`--until` flags instead. - Automatic log compaction (entries are kept verbatim — only age-based expiry). - Per-symbol reflog — per-ref is the correct granularity.