Add the /undo snapshot engine, anchored so it survives cleanup (part 1 of #944) - #1343
Open
KazenDev wants to merge 1 commit into
Open
Add the /undo snapshot engine, anchored so it survives cleanup (part 1 of #944)#1343KazenDev wants to merge 1 commit into
KazenDev wants to merge 1 commit into
Conversation
Snapshot capture, restore, and the per-chat journal. No UI yet: the picker, the command registry entry, and the send-message hook come in the follow-up. Three data-loss paths closed, each with a test that fails without it: - a failed `git add` no longer hands back the tree of a stale index - every entry is anchored (`refs/freebuff/undo/<chat>/<hash>`) so the cleanup job's prune cannot collect a snapshot the journal still lists - reverting checks the content, not just the tree: `git checkout` deletes a file whose blob it cannot read, and `ls-tree` still lists it The journal is the root set: refs are released when the entry is dropped, and `sweepAnchors` collects what a deleted chat left behind. Verified: 20 tests pass, the CLI typecheck has no new errors, and the full suite matches its baseline (+20 pass, same 61 pre-existing failures).
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First half of #944 (the
/undoand/redofeature), split as the review asked: "a smaller first PR (snapshot utility + store, no UI) before the full picker lands, so a maintainer can review the risky bits in isolation."So this is the engine only — snapshot capture, restore, and the per-chat journal. Nothing here is wired to the CLI: the
/undopicker, the command registry entry, and theuse-send-messagehook are in #1344.#944 was auto-closed by the repository history rewrite, and its base commit no longer exists, so this is re-applied on current
mainrather than rebased. The re-application brings no other change.What this is
Four new files, +1684, 0 deletions. No existing file is touched, so it cannot conflict with anything on
main:cli/src/utils/undo-snapshot.tscli/src/state/undo-store.tscli/src/utils/__tests__/undo-snapshot.test.tscli/src/state/__tests__/undo-store.test.tsOf those 1684 lines, 1025 are the original engine, carried over as-is; the rest is the fixes below and their tests. Measured against the original files, this is +686/-27 — the port survives, and only the pieces named below were replaced.
The snapshot repo is isolated (
--git-dir/--work-tree), reuses the project's objects viaobjects/info/alternates, and its cleanup job isgit gc --prune=7.days, at most once an hour per project (maybePrune).Inspired by OpenCode's snapshot service. Where this departs from it is the anchoring in the second fix below, and the reason is in that section.
Three ways it loses data
All three were reproduced first and each has a test that fails without its fix.
1. A failed
git addhands back a stale hashstageChangesignored whatgit addreturned, andwrite-treethen reports the tree of the stale index: a hash that looks valid and describes a state the turn never started from. Reproduced with an unreadable file:That is the same path OpenCode has reported in its own tracker (#10589, #12719):
/undoreverts to the wrong state.stageChangesnow reports success or failure andtrackSnapshotreturnsnullinstead of the stale hash.2. A snapshot nothing holds is collected
The tree from
write-treeis referenced by nothing: the journal keeps the hash, git does not know the object matters. The cleanup job's prune has a grace period of days, so this works at first and stops working later — for every entry, silently, once the grace expires.The review asked whether the snapshot store grows without bound and whether anything cleans it up. The answer is that
maybePrunedoes callgit gc --prune=7.days, so it is the other direction that is broken: the journal keeps up to 20 entries per chat with no expiry, while the objects behind them live only as long as the prune grace. The store is not the thing that grows; it is the thing that dies.Fixed by anchoring: each entry gets a commit and a ref under
refs/freebuff/undo/<chat>/<hash>, so it is reachable and the prune leaves it alone. The ref is released when the journal stops listing that hash (eviction past the 20-entry cap, or the redo stack a new turn clears).3. A snapshot whose content is gone is worse than one that is missing
Anchoring keeps what the snapshot repo writes, and the snapshot repo borrows the project's objects. So the project's own cleanup can collect a blob that a kept tree still lists — the tree survives, its content does not.
This is not a graceful failure. Measured with a tree that lists one missing blob:
git checkoutremoves the worktree file whose content it cannot read, andls-treegoes on listing it — so the old code destroyed the file and then reported it as restored (↺ orphan.txt). Data loss reported as success.That
checkout+ls-treepairing is inherited from the original; it is what the port carried over. What is new here is that anchoring makes it reachable: before, a snapshot's tree and its borrowed content died together, so the missing-tree guard caught both and this hole hid behind a coarser failure.snapshotIsCompletenow checks the content, not just the tree, and gates bothisSnapshotAvailableandrevertFiles:A snapshot with holes is refused up front. The worktree is left exactly as it is, nothing is restored, nothing is deleted.
What this adds over the original
The original captures a tree per turn and restores from it. This keeps that, and adds:
refs/freebuff/undo/<chat>/<hash>sweepAnchors, once per project per processundo.jsonwith it and leaves its refs behind; the sweep collects those, and re-anchors an entry whose ref went missinggit add's result checked beforewrite-treeNone of this changes how a snapshot is captured or how files are restored on the normal path. It changes when a snapshot is kept, and what happens when it cannot be read.
Where each point of the review is answered
recordUndoEntryruns even if the turn was replacedfinallylives. The engine's own writes are serialized bywithLock, and the anchor is created after the journal write on purpose, so the sweep can never see an entry that is not anchored yetEnterThe journal is the root set
The review's growth question is why there is a sweep and not just a release. Releasing on drop only covers the entries this store hands out; it cannot know about a chat that was deleted, whose
undo.jsonis gone and whose refs are still there. SosweepAnchorsmakes the anchors match the journals: release what no journal lists, re-anchor an entry whose ref went missing. It runs once per project per process, off the turn's path.This is the same problem GitLab has in production with
refs/keep-around— the identical mechanism — and their guidance is now explicit: track the refs you create, remove them when nothing needs them, and stop creating them without a lifecycle (Keep-around ref usage guidelines). Their older guidance to preferkeep-aroundwas reversed; the current one is to "consider alternative options such as scoped refs" and to stop adding new places that createkeep-aroundrefs (Gitaly development guidelines — "Becausekeep-aroundreferences have no lifecycle, don't use them for any new functionality").That describes this: a scoped namespace per chat, and a lifecycle owned by the journal rather than by the cleanup job's timer.
The release half has the same shape in Claude Code's checkpointing. It keeps snapshots for the most recent 100 checkpoints in a session, and "discarding an older checkpoint deletes the snapshot files that no remaining checkpoint references" (Checkpointing). Same rule — a snapshot lives while something still points at it — with the journal's entries playing the part of the checkpoints.
Not in this PR, on purpose
skippedcount out ofrevertFiles, which belongs with the UI half, where the summary is written.git repack -a -d(without--local) copies the borrowed objects in, measured — butgit gcre-packs with--localand drops them again, so closing it means replacing the maintenance command and paying a full repack plus a copy of the project's objects. That is its own change; tracked for the follow-up.checkout-index -a -frewrites every file on restore and bumps mtimes on files that did not change; the anchor commit pinsuser.name/user.email/commit.gpgsignbut nothing else from the user's git config; and there is no guard against snapshotting an unbounded root such as$HOMEor/.Verification
Public CI does not run this suite, so it was run locally, against the same tree with and without these files:
diffafter normalising timings is empty)undo+20 pass and +2 files is exactly the 20 tests in this PR, and the 61 failures are all pre-existing (release wrapper, prompts, locales). They were compared against a real baseline taken with these files stashed, not against memory.
How to try it
Each fix can be seen failing by reverting its piece alone:
chmod 000a file in the project, thentrackSnapshotreturns a hash today where it must returnnull.git gc --prune=nowthe snapshot repo: the anchored tree is still restorable, the unanchored one is not.git mktree --missing), then revert it: nothing is restored, nothing is deleted, and the file is still there.The six user-visible behaviours covered by the tests are unchanged: detecting modified/created/deleted files, restoring the worktree, deleting a file the turn created (that stays correct), never touching the project's real
.git, surviving a corruptundo.json, and persisting across restarts.