gabriel / muse public
reflog-complete.md markdown
226 lines 9.7 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago

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_01muse pull writes pull: fetched <remote>/<branch> after the branch pointer advances; entry includes old and new commit IDs
  • [x] RL_02muse shelf pop writes shelf-pop: restore from <entry-name> entry when HEAD moves
  • [x] RL_03muse shelf apply writes shelf-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-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 <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_06muse reflog expire --expire-days 90 --json removes entries older than 90 days from HEAD log; JSON reports expired count
  • [x] RL_07muse reflog expire --all --json applies to all branch logs; JSON lists all refs_processed
  • [x] RL_08muse reflog expire --dry-run --json reports what would be removed with "dry_run": true; writes nothing
  • [x] RL_09muse 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 <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_13muse reflog delete @{0} --json removes the newest entry; deleted: 1, remaining equals prior count minus one
  • [x] RL_14muse 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_18muse gc --json prunes reflog entries older than reflog.expire-days (default 90); JSON output gains a reflog_expired integer field
  • [x] RL_19muse 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_21muse rev-parse @{0} --json resolves to the commit ID at HEAD reflog index 0 (newest)
  • [x] RL_22muse reset @{1} --json resets HEAD to reflog index 1; subsequent muse read --json confirms the correct commit
  • [x] RL_23muse read @{2} --json reads the commit at HEAD reflog index 2
  • [x] RL_24dev@{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_26muse 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 <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_28muse reflog exists --json returns {"exists": true, "count": N} when HEAD log has entries; exit 0
  • [x] RL_29muse 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_01RL_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.
File History 4 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago