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, andmuse reflog existsexist 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 pullwritespull: fetched <remote>/<branch>after the branch pointer advances; entry includes old and new commit IDs - [x]
RL_02—muse shelf popwritesshelf-pop: restore from <entry-name>entry when HEAD moves - [x]
RL_03—muse shelf applywritesshelf-apply: restored from <entry-name>(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-onlyis 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 <name>] [--all] [--dry-run] [--json]
JSON schema
{
"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 --jsonremoves entries older than 90 days from HEAD log; JSON reportsexpiredcount - [x]
RL_07—muse reflog expire --all --jsonapplies to all branch logs; JSON lists allrefs_processed - [x]
RL_08—muse reflog expire --dry-run --jsonreports what would be removed with"dry_run": true; writes nothing - [x]
RL_09—muse config reflog.expire-days Nsets the default TTL; subsequentmuse reflog expirewith no--expire-daysuses 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 --jsonreturnsentries: []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 <name>] [--json]
muse reflog delete --all [--branch <name>] [--json]
JSON schema
{
"exit_code": 0,
"duration_ms": 1.1,
"deleted": 1,
"remaining": 5,
"ref": "HEAD"
}
Deliverables
- [x]
RL_13—muse reflog delete @{0} --jsonremoves the newest entry;deleted: 1,remainingequals prior count minus one - [x]
RL_14—muse reflog delete --all --jsonremoves all entries; log file is deleted;deletedequals prior entry count - [x]
RL_15— Out-of-bounds index exitsUSER_ERRORwith 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 --jsonprunes reflog entries older thanreflog.expire-days(default 90); JSON output gains areflog_expiredinteger field - [x]
RL_19—muse gc --dry-run --jsonreportsreflog_expiredcount 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} --jsonresolves to the commit ID at HEAD reflog index 0 (newest) - [x]
RL_22—muse reset @{1} --jsonresets HEAD to reflog index 1; subsequentmuse read --jsonconfirms the correct commit - [x]
RL_23—muse read @{2} --jsonreads 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 exitsUSER_ERRORwith message stating the valid range - [x]
RL_26—muse diff @{1} HEAD --jsonshows the diff between reflog state 1 and HEAD;added/modified/deletedfields are correct - [x]
RL_27— End-to-end recovery test: commit A → commit B →muse reset @{1}→muse read --jsonshows 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 <name>] [--json]
Exit 0 if entries exist; exit 1 if none.
JSON schema
{
"exit_code": 0,
"duration_ms": 0.3,
"ref": "HEAD",
"exists": true,
"count": 7
}
Deliverables
- [x]
RL_28—muse reflog exists --jsonreturns{"exists": true, "count": N}when HEAD log has entries; exit 0 - [x]
RL_29—muse reflog exists --branch dev --jsonreturns{"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 pullleaves a reflog trace every time a branch pointer advances.muse shelf popandmuse shelf applyleave a reflog trace when HEAD moves.muse gcprunes entries older thanreflog.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-runreports 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 --jsonfor changed files reports no regressions.
Out of Scope
- Remote reflog sharing — reflogs are always local-only, never pushed.
--formatoptions beyond the existing text/JSON modes.- Relative time syntax (
@{1.hour.ago}) — use--since/--untilflags instead. - Automatic log compaction (entries are kept verbatim — only age-based expiry).
- Per-symbol reflog — per-ref is the correct granularity.