Assistants How-to

A git snapshot before every prompt: the undo that covers what /rewind skips

Claude Code's /rewind only restores files its own edit tools touched. A 40-line UserPromptSubmit hook gives you a restore point for everything else, and it takes five minutes to set up.

Part of the hooks reading guide

Five clay shutter discs on a shelf, the middle one glowing peach, with a curled arrow looping back towards the first

Claude Code 2.1.309, released on 22 October, changed how sed -i behaves: it now gets the standard Bash permission prompt, runs exactly as written, and is no longer restored by /rewind . That last part is the interesting one. If a stray in-place edit mangles forty files, the rewind menu may not know about them.

The surprise is mostly in how narrow checkpointing is. Per the checkpointing docs , Claude Code snapshots files that its own edit tools change. A file that only a shell command touched isn’t tracked, and a subagent’s edits usually aren’t either. Git doesn’t have those blind spots.

So let git do it. A UserPromptSubmit hook can write the whole working tree into a commit before each prompt, park it under a private ref, and leave your branch, index and working tree alone. Restoring is one command. Here’s the build.

What /rewind covers, and what it doesn’t

The docs list the gaps plainly:

  • A file changed only by a Bash command (a codemod, sed -i, a generator) isn’t tracked. Once Claude has edited a file with its own tools, later Bash changes to that file are undone too, but not before.
  • Subagent edits are usually not captured. Rewind restores only the files Claude had already edited in your conversation.
  • Symlinked and hard-linked files are skipped on restore, which catches dotfile-managed configs and files pnpm hard-links into place.
  • Manual edits and changes from other concurrent sessions are normally not captured.

None of this is a bug. Checkpoints are session-level recovery for Claude’s own edits, and the docs say so: use version control for anything permanent. The hook below is what that sentence looks like in practice.

The hook

It runs as a Python script, because it needs no install step and handles JSON cleanly. Save it as .claude/hooks/snapshot.py in the repo:

#!/usr/bin/env python3
"""UserPromptSubmit hook: save the working tree as a commit under refs/claude-snapshots/."""
import json, os, subprocess, sys, tempfile, time

KEEP = 50  # snapshots kept per repo

def git(*args, cwd, env=None):
    return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True).stdout.strip()

def main():
    data = json.load(sys.stdin)
    cwd = data["cwd"]
    try:
        git("rev-parse", "--git-dir", cwd=cwd)
    except subprocess.CalledProcessError:
        return  # not a repo: do nothing, never block the prompt

    # A throwaway index, so the real index and working tree are never touched.
    with tempfile.TemporaryDirectory() as tmp:
        env = {**os.environ, "GIT_INDEX_FILE": os.path.join(tmp, "index")}
        try:
            git("read-tree", "HEAD", cwd=cwd, env=env)
        except subprocess.CalledProcessError:
            pass  # no commits yet
        git("add", "-A", cwd=cwd, env=env)  # tracked changes plus untracked, minus ignored
        tree = git("write-tree", cwd=cwd, env=env)

    parents = []
    try:
        parents = ["-p", git("rev-parse", "HEAD", cwd=cwd)]
    except subprocess.CalledProcessError:
        pass
    msg = f"snapshot before prompt {data.get('prompt_id', '?')}"
    commit = git("commit-tree", tree, *parents, "-m", msg, cwd=cwd)

    stamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
    prompt = data.get("prompt_id", "noprompt")[:8]
    git("update-ref", f"refs/claude-snapshots/{stamp}-{prompt}", commit, cwd=cwd)

    refs = git("for-each-ref", "--format=%(refname)", "refs/claude-snapshots/", cwd=cwd).splitlines()
    for old in sorted(refs)[:-KEEP]:
        git("update-ref", "-d", old, cwd=cwd)

main()

Three details carry the design.

The temporary index matters most. git stash create is the obvious tool, but it only captures tracked files, so a new file an agent just wrote would vanish from the restore point. Building a tree from a private GIT_INDEX_FILE with git add -A captures untracked files too, honours .gitignore, and never touches the index you’ve been staging into.

The ref lives outside refs/heads and refs/tags, so it doesn’t show up in git branch, git log or your editor’s branch picker. It’s still a real ref, which means git gc won’t collect the objects behind it.

And the script never blocks. Per the hooks reference , exit code 2 blocks a prompt while any other non-zero code is a non-blocking error, so a crash costs you one missing snapshot, not a stuck session. Outside a git repo it returns without doing anything.

The ref name carries a UTC timestamp and the first eight characters of the hook’s prompt_id. Keying on the session id instead would be a mistake: two prompts in the same second would write to the same ref and the second would overwrite the first.

Wiring it in

Add the hook to .claude/settings.json. Use the exec form, which avoids shell quoting around the path:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/snapshot.py"]
          }
        ]
      }
    ]
  }
}

UserPromptSubmit takes no matcher, so there’s nothing to filter on: it fires for every prompt, and also for turns Claude Code starts on its own. On Windows, swap python3 for python or py. Claude Code lowers the default command timeout on this event to 30 seconds, which is far more than a snapshot needs on a normal repository.

The commands below pipe the script the same JSON a hook receives. How Claude Code invokes it follows the documentation.

Run it

Here’s a small repo with a git-ignored .env, a snapshot taken before the first prompt, and a sed -i plus a new file standing in for the agent:

printf 'export const rate = 0.2;\n' > pricing.ts
printf 'secret=1\n' > .env && echo .env > .gitignore
git add -A && git commit -qm init

echo '{"session_id":"s1","prompt_id":"11111111-aaaa","cwd":"'$PWD'"}' | python3 snapshot.py
sed -i 's/0.2/0.25/' pricing.ts
echo 'draft' > notes.ts
sleep 1
echo '{"session_id":"s1","prompt_id":"22222222-bbbb","cwd":"'$PWD'"}' | python3 snapshot.py
sed -i 's/0.25/0.99/' pricing.ts

git for-each-ref refs/claude-snapshots/ --format='%(refname:short)  %(subject)'
claude-snapshots/20261024T160329-11111111  snapshot before prompt 11111111-aaaa
claude-snapshots/20261024T160330-22222222  snapshot before prompt 22222222-bbbb

Now the second prompt went wrong and pricing.ts says 0.99. Compare the working tree against the latest snapshot:

S=$(git for-each-ref refs/claude-snapshots/ --format='%(refname)' | tail -1)
git diff $S
diff --git a/notes.ts b/notes.ts
deleted file mode 100644
index f3d4377..0000000
--- a/notes.ts
+++ /dev/null
@@ -1 +0,0 @@
-draft
diff --git a/pricing.ts b/pricing.ts
index 66f38af..50c5aa5 100644
--- a/pricing.ts
+++ b/pricing.ts
@@ -1 +1 @@
-export const rate = 0.25;
+export const rate = 0.99;

The notes.ts “deletion” is an artefact: git diff against a ref doesn’t look at untracked files in the working tree, so a file that exists on disk but isn’t tracked reads as missing. It’s still in the snapshot. The part that matters is pricing.ts: the diff is exactly what changed since that prompt, whatever made the change.

Restore it:

git restore --source=$S --worktree -- .
cat pricing.ts
export const rate = 0.25;

The --worktree flag matters. It writes the files back without touching the index, so anything you’d staged stays staged. Two limits apply. Restore overwrites tracked and snapshotted files but doesn’t delete files created after the snapshot, so remove those yourself (git status --short lists them). And to take back one file only, give its path instead of ..

The .env stayed out of the snapshot, as it should. git ls-tree -r --name-only $S lists .gitignore, notes.ts and pricing.ts, nothing else. Running git gc --prune=now afterwards left the snapshot commit intact, since the ref keeps it reachable.

Make it yours

Snapshot only on turns where the agent can write. The hook input carries a permission_mode field, with values including plan. Skip the snapshot when it’s plan and the repo stays quieter.

Raise or lower KEEP. Fifty is generous for one day’s work. Pruning removes refs, not objects, so the disk space comes back at the next git gc, which git runs on its own now and then.

Add a small shell alias to list and restore. A function that takes a ref and runs the git restore above is enough, and a second one that runs git diff --stat against it tells you what a restore would change before you commit to it.

A snapshot per prompt isn’t the only boundary worth recording. For a restore point after each response instead, the same idea works on a Stop hook (the hooks reference lists prompt_id among the fields both events receive).

What goes wrong

Large untracked trees. git add -A stages every untracked, non-ignored file, so a build output directory missing from .gitignore goes into every snapshot. Fix the ignore rule rather than the hook.

Secrets that aren’t ignored. A credentials file that’s untracked and not in .gitignore ends up in a snapshot commit. The commit stays local, and refs outside refs/heads aren’t pushed by a plain git push, but a mirror push (git push --mirror) sends every ref. Use .gitignore, as you should already.

Submodules and nested repos. The hook snapshots the repo at cwd and nothing beneath another repo’s boundary.

It isn’t a substitute for committing. A snapshot is a restore point for the last few hours, not history anyone else can read. Commit when a piece of work is right.

Skip all of this if the agent already works in a throwaway worktree or a cloud session that gets discarded: a bad turn costs you nothing there.

First five minutes

Create .claude/hooks/snapshot.py from the listing, add the JSON to .claude/settings.json, and start a session in a repo with a clean working tree. Send any prompt, then run git for-each-ref refs/claude-snapshots/ in another terminal. One line per prompt means it’s working. Next time a shell command rewrites something /rewind doesn’t list, git diff against the latest ref shows you what it did.