DEV Community

Cover image for Undo One Bad Turn From Claude Code Without Losing the Ten Good Ones
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Undo One Bad Turn From Claude Code Without Losing the Ten Good Ones


Turn 11 ends and you run the build. It fails.

You scroll back through the session. Turns 1 to 8 moved three route handlers to the new validator and added a test for each. Turn 9 was the agent "tidying" tsconfig.json, and that is where the build broke. Turns 10 and 11 fixed two unrelated lint errors. Your last commit is from two hours ago.

git checkout . throws away ten good turns to get rid of one bad one. Claude Code's rewind can take you back to before turn 9, but turns 10 and 11 go with it, and it doesn't track anything the agent changed through shell commands.

What you want is a restore point for every turn, and a way to back out one turn while keeping the rest. Git can do both, with one shell script and three hooks. At the end, how Hermes IDE handles this. I build it.

What Claude Code's checkpoints already give you

Claude Code captures the state of your code before each prompt that starts a turn (checkpointing docs). Press Esc twice with an empty prompt, or run /rewind, pick one of your earlier prompts, and choose Restore code, Restore conversation, or Restore code and conversation. It keeps file snapshots for the 100 most recent checkpoints in a session, they survive a resume, and they are swept about 30 days after the session last saved one unless you raise cleanupPeriodDays.

That covers a lot. The same page lists what it doesn't cover:

  • Files modified by Bash commands are not tracked. The docs use rm, mv and cp as examples. By the same rule, a sed -i across ten files, a formatter run from the shell, or an npm install that rewrites the lockfile is invisible to rewind.
  • Edits made by subagents are usually not restored. The docs say to use git to revert those.
  • Restore works on a point in time. You go back to before turn 9, and everything after it goes too.

The docs are direct about the scope: checkpoints are "designed for quick, session-level recovery," and they are "not a replacement for version control."

If the bad turn is the last one and the agent only used its file editing tools, rewind is the right tool. Everything else needs git.

One hidden git ref per turn

Git can commit your working tree without touching your branch. The trick is a throwaway index:

  1. Copy your real index to a temp file and point GIT_INDEX_FILE at the copy.
  2. git add -A into that copy, so the snapshot holds every file git doesn't ignore, untracked ones included.
  3. git write-tree turns it into a tree, and git commit-tree wraps the tree in a commit.
  4. git update-ref gives that commit a name under refs/turns/.

Your branch, your staging area and your stash never move. The ref isn't under refs/heads/, so git branch doesn't list it, and a plain git push with the default push.default=simple only pushes the current branch (git-push docs). It isn't invisible, though: git log --all treats every ref in refs/ as a starting point, and git push --mirror would send these refs to the remote.

Each turn gets two snapshots. pre is taken when you send a prompt, post when the turn ends. The turn's diff is pre to post, so an edit you make by hand between turns never lands in the agent's turn. And because the snapshot reads the tree, it catches whatever changed during the turn, whether the agent used its edit tool or sed.

A row of cream snapshot cards hanging from a hidden rail beneath a black branch line, one card in burnt orange
Every turn leaves a snapshot on a rail of its own, out of the way of your branch.

The script and the hooks

Claude Code hooks run a shell command on lifecycle events and pass a JSON object on stdin with fields including session_id and cwd. Three events matter here: UserPromptSubmit fires "when a prompt is submitted, before Claude processes it", Stop fires "when Claude finishes responding", and StopFailure fires "when the turn ends due to an API error".

Save this as .claude/turns.sh. It needs bash, jq, and git with user.name and user.email set, since git commit-tree uses them.

#!/usr/bin/env bash
# .claude/turns.sh: one hidden git snapshot per agent turn
set -euo pipefail
t=

# Commit the working tree as it is now. Your branch, index and
# stash stay untouched. Prints the new commit id.
snapshot() {
  local tmp idx tree
  tmp=$(mktemp -d)
  idx=$(git rev-parse --git-path index)
  if [ -f "$idx" ]; then cp "$idx" "$tmp/index"; fi
  GIT_INDEX_FILE="$tmp/index" git add -A
  tree=$(GIT_INDEX_FILE="$tmp/index" git write-tree)
  rm -rf "$tmp"
  git commit-tree -m "$1" "$tree"
}

# Run by Claude Code: "hook pre" when you send a prompt,
# "hook post" when the turn ends. The hook JSON is on stdin.
hook() {
  exec 1>&2  # UserPromptSubmit stdout would reach Claude
  local input sid n ref
  input=$(cat)
  sid=$(jq -r .session_id <<<"$input")
  cd "$(jq -r .cwd <<<"$input")"
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
  cd "$(git rev-parse --show-toplevel)"
  # Remember which session belongs to this worktree.
  echo "$sid" >"$(git rev-parse --git-path turns-session)"
  n=$(( $(git for-each-ref "refs/turns/$sid/*/pre" | wc -l) ))
  if [ "$1" = pre ]; then n=$((n + 1)); fi
  ref=$(printf 'refs/turns/%s/%03d/%s' "$sid" "$n" "$1")
  git update-ref "$ref" "$(snapshot "turn $n $1")"
}

# Ref for turn $1, side $2. An interrupted turn has no post,
# so use the next turn's pre, or the tree as it is now.
at() {
  local n=$((10#$1)) r
  r=$(printf '%s/%03d/%s' "$t" "$n" "$2")
  if [ "$2" = post ] && ! git rev-parse -q --verify "$r" >/dev/null
  then
    r=$(printf '%s/%03d/pre' "$t" "$((n + 1))")
    git rev-parse -q --verify "$r" >/dev/null ||
      r=$(snapshot "turn $n now")
  fi
  echo "$r"
}

# Make the working tree match a commit. Ignored files stay.
restore_to() {
  local target now
  target=$(git rev-parse --verify "$1^{commit}")
  now=$(snapshot "before restore")
  git update-ref "$t/undo" "$now"
  git diff --binary "$target" "$now" | git apply -R
}

list() {
  local pre n
  git for-each-ref --format='%(refname)' "$t/*/pre" |
  while read -r pre; do
    n=${pre%/pre}; n=${n##*/}
    echo "turn $n:$(git diff --shortstat "$pre" "$(at "$n" post)")"
  done
}

cli() {
  cd "$(git rev-parse --show-toplevel)"
  local f sid
  f=$(git rev-parse --git-path turns-session)
  sid=${SID:-$(cat "$f" 2>/dev/null || true)}
  [ -n "$sid" ] || { echo "no turns recorded here" >&2; exit 1; }
  t="refs/turns/$sid"
  case "${1:-list}" in
    list) list ;;
    diff) git diff "$(at "$2" pre)" "$(at "$2" post)" ;;
    undo)
      git update-ref "$t/undo" "$(snapshot "before undo")"
      git diff --binary "$(at "$2" pre)" "$(at "$2" post)" |
        git apply -R ;;
    back) restore_to "$(at "$2" post)" ;;
    oops) restore_to "$t/undo" ;;
    *) echo "usage: turns.sh list|diff N|undo N|back N|oops" >&2
       exit 2 ;;
  esac
}

if [ "${1:-}" = hook ]; then hook "$2"; else cli "$@"; fi
Enter fullscreen mode Exit fullscreen mode

For UserPromptSubmit, Claude Code adds plain-text stdout to Claude's context, so anything git printed there would be read by the model. The exec 1>&2 line sends it to stderr and keeps the hook silent.

Refs end up as refs/turns/<session-id>/009/pre and .../009/post. Keying them by session keeps two Claude Code sessions in the same repository, for example in two worktrees, from numbering over each other, since worktrees share refs under refs/, refs/turns/ included. The hook also writes the session id to a turns-session file that git rev-parse --git-path places in each worktree's own git directory, so the commands below only ever pick up the turns of the session running in the worktree you're in.

Wire it up in .claude/settings.json. None of these entries needs a matcher, so each one is just the command. CLAUDE_PROJECT_DIR is the project root, which Claude Code exports into the hook's environment. It's wrapped in double quotes, as the hooks reference recommends for shell-form commands, so a project path with spaces still works:

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command",
      "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/turns.sh hook pre"
    }] }],
    "Stop": [{ "hooks": [{ "type": "command",
      "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/turns.sh hook post"
    }] }],
    "StopFailure": [{ "hooks": [{ "type": "command",
      "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/turns.sh hook post"
    }] }]
  }
}
Enter fullscreen mode Exit fullscreen mode

UserPromptSubmit hooks have a default timeout of 30 seconds, per the hooks reference. Starting from a copy of your real index lets git add -A skip rehashing files whose size and timestamps haven't changed since you last staged them.

A turn can still end without a post ref. If you press Esc to stop a turn that's going wrong, Stop doesn't fire: the hooks reference says it "does not run if the stoppage occurred due to a user interrupt." The at helper handles that by using the next turn's pre as the end of the interrupted turn, or a fresh snapshot of your tree if no turn has started since.

Diff, undo or go back one turn

Make the script executable once, then use it from the repository root:

chmod +x .claude/turns.sh
.claude/turns.sh list     # files and lines changed per turn
.claude/turns.sh diff 9   # what turn 9 changed
.claude/turns.sh undo 9   # back out turn 9, keep 10 and 11
.claude/turns.sh back 8   # whole tree as turn 8 left it
.claude/turns.sh oops     # reverse the last undo or back
SID=<session-id> .claude/turns.sh list   # an older session
Enter fullscreen mode Exit fullscreen mode

list runs git diff --shortstat between each turn's pre and post, so you can find the turn that touched tsconfig.json without reading every diff.

undo 9 backs out one turn and keeps the rest. It takes turn 9's patch and applies it in reverse on top of your current files, with turns 10 and 11 still in place. If a later turn edited the same lines, the reverse patch won't fit. The git-apply docs cover that case: "For atomicity, git apply by default fails the whole patch and does not touch the working tree when some of the hunks do not apply." You get an error and unchanged files, never half an undo.

A stack of transparent layers over a block structure, one cracked burnt-orange layer in the middle slid out sideways while the rest stay aligned
undo pulls one layer out of the stack. back lifts off everything above a layer.

back 8 is the blunt version: it makes your tree match the end of turn 8, including deleting files that later turns created. That also discards edits you made by hand after turn 8, so check list first.

Both undo and back snapshot your current tree to refs/turns/<session-id>/undo before they touch anything. If you went back too far, oops puts you where you were.

Then run the build again, and commit once it's green. A turn snapshot is a safety net for the next hour. Your branch is still where the real history lives.

What this setup doesn't cover

  • Turn numbers count every prompt Claude Code processes. Per the hooks reference, UserPromptSubmit also fires for prompts you didn't type, such as scheduled tasks and /loop iterations, so "turn 9" in the refs may not be the ninth message you sent. Run diff 9 before undo 9.
  • Ignored files aren't in the snapshots. If turn 9 ran npm install, undoing it restores package-lock.json but not node_modules, so reinstall afterwards.
  • The turn diff is everything that changed between pre and post. If you run two agents in the same checkout, each one's turns include the other's edits. Give each agent its own worktree.
  • A large untracked file that isn't in .gitignore gets copied into .git on the first snapshot. Keep your ignore file honest.
  • /rewind and this script don't know about each other. Restoring code with one doesn't move the other's snapshots.
  • The refs stay until you delete them. To clear them all, using the delete command that git update-ref --stdin accepts:
git for-each-ref --format='delete %(refname)' refs/turns |
  git update-ref --stdin
Enter fullscreen mode Exit fullscreen mode

After that, the snapshot commits are unreachable and git's normal garbage collection removes them in time.

The same idea in Hermes IDE

I build Hermes IDE, so weigh this section with that in mind. It runs Claude Code, Codex, GitHub Copilot CLI, OpenCode and other agents, each in its own terminal, and it records turns for you, with no hook to write.

Per the README, every agent turn is saved, so you can diff or restore any single turn, with undo. Hermes 2.3.1, released on October 13, 2026 (changelog), labels the strip under each session "Agent replies". Each chip is one reply with the lines it changed (+added −removed). Click one to see its changes or to restore your files to that point. When there are more replies than fit, a "+N earlier" chip opens a list of every reply.

For the scenario at the top, open the Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux), which shows changes by turn, and read turn 9's changes on their own. If turn 9 was the last reply, restoring your files to the point before it is the whole fix.

Two limits from the release notes. In a task with extra folders, the strip tracks changes in the main project only. And for tasks that ran in their own worktree, turn snapshots are removed from the repository 14 days after the session closes. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Anthropic, OpenAI or Nous Research. Download it or read the source on GitHub.


If this was useful

Try the script on your next long session, or let Hermes IDE keep the turns for you, and if it saves you a rebuild, a star on GitHub helps other developers find it.

The bad turn in this post was an agent "tidying" tsconfig.json. TypeScript in Production covers tsconfig and the build tools around it, the layer where a one-line agent edit can break everything downstream, so you can read a diff like turn 9's and see what it changed. It's book 5 of The TypeScript Library: if you ship TypeScript at work, start there; if you're still getting comfortable with the language, books 1 and 2 are the core path, and books 3 and 4 replace them if you come from the JVM or PHP.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)