muse reflog — complete coverage, lifecycle management, and @{N} resolution
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
RL_01—muse pullwritespull: fetched <remote>/<branch>after the branch pointer advances; entry includes old and new commit IDsRL_02—muse shelf popwritesshelf-pop: restore from <entry-name>entry when HEAD movesRL_03—muse shelf applywritesshelf-apply: restored from <entry-name>(keeps the shelf entry, no pop) when HEAD movesRL_04— Reflog writes in all three operations are non-fatal: a write failure logs a warning but never aborts the operationRL_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
RL_06—muse reflog expire --expire-days 90 --jsonremoves entries older than 90 days from HEAD log; JSON reportsexpiredcountRL_07—muse reflog expire --all --jsonapplies to all branch logs; JSON lists allrefs_processedRL_08—muse reflog expire --dry-run --jsonreports what would be removed with"dry_run": true; writes nothingRL_09—muse config reflog.expire-days Nsets the default TTL; subsequentmuse reflog expirewith no--expire-daysuses itRL_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)RL_11— Integration test: 100-entry log with 50 entries older than threshold → exactly 50 removed, 50 kept; verified by re-reading the logRL_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
RL_13—muse reflog delete @{0} --jsonremoves the newest entry;deleted: 1,remainingequals prior count minus oneRL_14—muse reflog delete --all --jsonremoves all entries; log file is deleted;deletedequals prior entry countRL_15— Out-of-bounds index exitsUSER_ERRORwith a message that includes the valid range (e.g. "valid range: 0–4")RL_16— Delete is atomic (write-tmp + rename), same guarantee as expireRL_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
RL_18—muse gc --jsonprunes reflog entries older thanreflog.expire-days(default 90); JSON output gains areflog_expiredinteger fieldRL_19—muse gc --dry-run --jsonreportsreflog_expiredcount alongside object counts; writes nothingRL_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
RL_21—muse rev-parse @{0} --jsonresolves to the commit ID at HEAD reflog index 0 (newest)RL_22—muse reset @{1} --jsonresets HEAD to reflog index 1; subsequentmuse read --jsonconfirms the correct commitRL_23—muse read @{2} --jsonreads the commit at HEAD reflog index 2RL_24—dev@{0}resolves to the dev branch reflog's newest entry commit IDRL_25— Out-of-range index exitsUSER_ERRORwith message stating the valid rangeRL_26—muse diff @{1} HEAD --jsonshows the diff between reflog state 1 and HEAD;added/modified/deletedfields are correctRL_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
RL_28—muse reflog exists --jsonreturns{"exists": true, "count": N}when HEAD log has entries; exit 0RL_29—muse reflog exists --branch dev --jsonreturns{"exists": false, "count": 0}for a branch with no log file; exit 1RL_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.
All 30 deliverables (RL_01–RL_30) are implemented and green. Closing.