Skip to content

Add the /undo snapshot engine, anchored so it survives cleanup (part 1 of #944) - #1343

Open
KazenDev wants to merge 1 commit into
CodebuffAI:mainfrom
KazenDev:feat/undo-snapshot-store
Open

Add the /undo snapshot engine, anchored so it survives cleanup (part 1 of #944)#1343
KazenDev wants to merge 1 commit into
CodebuffAI:mainfrom
KazenDev:feat/undo-snapshot-store

Conversation

@KazenDev

@KazenDev KazenDev commented Sep 13, 2026

Copy link
Copy Markdown

First half of #944 (the /undo and /redo feature), 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 /undo picker, the command registry entry, and the use-send-message hook 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 main rather 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:

File Lines
cli/src/utils/undo-snapshot.ts 751
cli/src/state/undo-store.ts 406
cli/src/utils/__tests__/undo-snapshot.test.ts 257
cli/src/state/__tests__/undo-store.test.ts 270

Of 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 via objects/info/alternates, and its cleanup job is git 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 add hands back a stale hash

stageChanges ignored what git add returned, and write-tree then 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:

$ chmod 000 blocked.txt
$ git add -A             # exit 128: "unable to index file" / permission denied
$ git write-tree         # exit 0, prints a hash  <-- the turn keeps this

That is the same path OpenCode has reported in its own tracker (#10589, #12719): /undo reverts to the wrong state. stageChanges now reports success or failure and trackSnapshot returns null instead of the stale hash.

2. A snapshot nothing holds is collected

The tree from write-tree is 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 maybePrune does call git 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).

$ git --git-dir <snapshot> gc --prune=now
  anchored   -> still restorable
  unanchored -> collected

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 --git-dir <snapshot> checkout <tree> -- orphan.txt
error: unable to read sha1 file of orphan.txt (0123...)
# exit 255, and orphan.txt is DELETED from the worktree

$ git --git-dir <snapshot> ls-tree <tree> -- orphan.txt
100644 blob 0123...    orphan.txt        # exit 0, still listed

git checkout removes the worktree file whose content it cannot read, and ls-tree goes 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-tree pairing 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.

snapshotIsComplete now checks the content, not just the tree, and gates both isSnapshotAvailable and revertFiles:

$ git --git-dir <snapshot> rev-list --objects --missing=print <tree>
<tree> real.txt              # whole snapshot
<tree> ?0123... orphan.txt   # one command, and `?` marks what is gone

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:

Addition Why it is there
Each entry is anchored — a commit under refs/freebuff/undo/<chat>/<hash> A tree nothing references is collected by the cleanup job's prune, and the journal goes on offering an undo that no longer works
The ref is released when the journal drops the entry (past the 20-entry cap, or a new turn clearing the redo stack) Anchoring without releasing is worse than not anchoring: the refs accumulate with nothing bounding them
sweepAnchors, once per project per process Releasing on drop only covers the entries this store hands out. A chat that was deleted takes its undo.json with it and leaves its refs behind; the sweep collects those, and re-anchors an entry whose ref went missing
A content check before reverting, not just a tree check See fix 3: a tree can outlive its content, and reverting against that deletes files
git add's result checked before write-tree See fix 1

None 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

Review point Answer
Split it into a smaller first PR This PR is that split; the UI half is #1344
Does the snapshot store grow without bound? Fixed and tested: release on drop, plus the sweep below
Race: recordUndoEntry runs even if the turn was replaced Answered in #1344, where that finally lives. The engine's own writes are serialized by withLock, and the anchor is created after the journal write on purpose, so the sweep can never see an entry that is not anchored yet
The picker should say the undo cascades Answered in #1344: copy the user sees, before Enter

The 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.json is gone and whose refs are still there. So sweepAnchors makes 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 prefer keep-around was reversed; the current one is to "consider alternative options such as scoped refs" and to stop adding new places that create keep-around refs (Gitaly development guidelines"Because keep-around references 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

  • A file that could not be restored is skipped silently. Fix 3 refuses a snapshot with holes before touching anything, but a checkout that fails for another reason — a locked file, a permission — is left out of the summary instead of being counted. The message stays true (it no longer claims a restore that did not happen) and it also does not say "1 file could not be restored". Reporting that needs a skipped count out of revertFiles, which belongs with the UI half, where the summary is written.
  • The snapshot repo is not self-contained. It borrows the project's objects, so objects it only borrows remain the project's to keep; anchoring keeps what it writes. Making it own everything is possible — git repack -a -d (without --local) copies the borrowed objects in, measured — but git gc re-packs with --local and 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.
  • Quality items left alone: checkout-index -a -f rewrites every file on restore and bumps mtimes on files that did not change; the anchor commit pins user.name/user.email/commit.gpgsign but nothing else from the user's git config; and there is no guard against snapshotting an unbounded root such as $HOME or /.

Verification

Public CI does not run this suite, so it was run locally, against the same tree with and without these files:

without with
Full suite 6433 pass, 61 fail, 478 files 6453 pass, 61 fail, 480 files
Failures identical set (diff after normalising timings is empty)
This engine's tests 20 pass, 0 fail
CLI typecheck 10 errors 10 errors, none in 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

# this engine only
bun test cli/src/utils/__tests__/undo-snapshot.test.ts cli/src/state/__tests__/undo-store.test.ts
bun run --cwd cli typecheck

Each fix can be seen failing by reverting its piece alone:

  1. stale hashchmod 000 a file in the project, then trackSnapshot returns a hash today where it must return null.
  2. collected snapshot — anchor a tree, move the snapshot index on with two more turns, git gc --prune=now the snapshot repo: the anchored tree is still restorable, the unanchored one is not.
  3. missing content — build a tree that lists a blob which exists nowhere (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 corrupt undo.json, and persisting across restarts.

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant