SLOPSHOPPER

auto-checkpoint

Snapshots the working tree into hidden git refs at the start of every turn, with /checkpoints to list them and /undo-turn to roll back.

newcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-checkpoint
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /checkpoints ⎿ auto-checkpoint: No checkpoints yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Auto Checkpoint

Takes a hidden git snapshot of your working tree at the start of every turn, and gives you /checkpoints to list them and /undo-turn to roll the tree back to the latest one.

What this shows

turn.start, command.run and $.process.run driving plain git, $.command.register, and $.ui.ask as a confirmation gate before a destructive action.

Demo

Headless claude -p with the mod loaded in a throwaway repo (a.txt committed, wip.txt untracked), asked to create notes.txt. Before the turn's edit, one ref appeared:

4ca5ef7 commit	refs/claude-checkpoints/1791077022-2280d44f-9391-44e3-912f-89e3fcb59e5a
tree: a.txt wip.txt        (notes.txt did not exist yet)
status afterwards: ?? notes.txt, ?? wip.txt   (index and stash untouched)

/undo-turn and /checkpoints were exercised against real git through the mod's own scripts (see Notes), not in an interactive session.

How it works

  • turn.start: builds a commit of the whole working tree (tracked + untracked, honouring .gitignore) through a temporary GIT_INDEX_FILE (read-tree HEAD, add -A, write-tree, commit-tree -p HEAD) and points refs/claude-checkpoints/<epoch>-<turnId> at it. Your index, HEAD, branches, stash and files are never touched; only that ref and the object database are written. Then it deletes every checkpoint ref beyond the newest keep (default 20).
  • /checkpoints: name, age, and git diff --shortstat between each checkpoint and the working tree now (untracked files included).
  • /undo-turn [name]: shows the diffstat and the files it would delete, asks via $.ui.ask (Restore / Cancel; dismissed counts as Cancel), writes refs/claude-checkpoints/pre-undo-<epoch> first so the undo is itself undoable (/undo-turn pre-undo-<epoch>), restores contents with git restore --source=<commit> --worktree -- . (index and HEAD untouched), then removes only files that are untracked now, absent from the checkpoint tree, and newer than the checkpoint commit. No git clean, no reset --hard. Registered without immediate, so it waits for the turn to end.
  • Without a git repo (or git) everything is a no-op and the commands say so.

What claude plugin validate reports:

hooks: turn.start, session.start, command.run{command=checkpoints}, command.run{command=undo-turn}
calls: $.clock.now (via epoch), $.command.register, $.process.run (via sh), $.ui.ask

Run it

Requires Claude Code 2.1.289 or later, git 2.23+ (git restore) and bash on PATH. Setting keep is under the plugin's /config rows.

claude --plugin-dir ./mods/auto-checkpoint

Notes / limitations

  • Verified against real git in a throwaway repo by importing the hooks module with a shell-backed $: snapshot with index byte-identical afterwards, pre-existing untracked file captured, ignored files excluded, pruning to the cap, undo removing only new files, redo via pre-undo-*, no-repo and empty-repo (root commit). The unit tests use a scripted fake git; the live -p run covers turn.start only.
  • Snapshots copy file contents into git objects: large untracked binaries make each turn slower and the repo bigger until git gc prunes pruned refs' objects.
  • Undo reverts everything changed since the checkpoint, including your own edits made during the turn. That is why it asks, and why it snapshots first.
  • Deleted-then-restored directories are fine; empty directories left by removed files stay. File names containing newlines are not handled.
  • Two undos in the same second share a pre-undo-<epoch> name (the first is overwritten). Sub-second turns tie-break by ref name when pruning.
  • Ignored files are never snapshotted, so undo cannot bring them back and never deletes them.
  • Not a sandbox: a bash script run by the mod, with the repo found from the session directory (nested repos and submodules are not snapshotted separately).
  • Does not use the AbovePrompt band.

Dependencies

None.

Source 1 files
hooks/auto-checkpoint.mjs 159 lines
1// Auto Checkpoint: a hidden git snapshot of the working tree at the start of every turn.
2//
3// turn.start: build a commit of the whole tree (tracked + untracked, minus .gitignore)
4//   through a temporary index, point refs/claude-checkpoints/<epoch>-<turnId> at it, prune.
5//   The user's index, HEAD, branches, stash and working tree are never touched.
6// command.run: /checkpoints lists them, /undo-turn restores the latest after a confirmation.
7//
8// The host reads `on(...)` and `$.noun.method(...)` from source, so they are spelled literally.
9
10const NOT_REPO = "Not a git repository (or git is missing): auto-checkpoint does nothing here.";
11
12// Shared bash prelude. $1.. are the script's args. Defines snap NAME MSG: prints the commit sha.
13const PRELUDE = `
14set -u
15git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo NOTREPO; exit 0; }
16cd "$(git rev-parse --show-toplevel)" || exit 1
17export GIT_AUTHOR_NAME="\${GIT_AUTHOR_NAME:-claude-checkpoint}" GIT_AUTHOR_EMAIL="\${GIT_AUTHOR_EMAIL:-checkpoint@localhost}"
18export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
19# tree of the working tree right now (tracked + untracked, minus ignored), via a throwaway index
20now_tree() {
21  idx=$(mktemp) || return 1
22  rm -f "$idx" # a 0-byte file is not a valid index
23  GIT_INDEX_FILE=$idx git add -A >/dev/null 2>&1 && GIT_INDEX_FILE=$idx git write-tree
24  rm -f "$idx"
25}
26snap() {
27  idx=$(mktemp) || return 1
28  GIT_INDEX_FILE=$idx; export GIT_INDEX_FILE
29  if git rev-parse -q --verify HEAD >/dev/null; then git read-tree HEAD; set -- "$@" -p HEAD; else git read-tree --empty; fi
30  git add -A >/dev/null 2>&1
31  tree=$(git write-tree) && c=$(git commit-tree "$tree" "\${@:3}" -m "$2") && git update-ref "refs/claude-checkpoints/$1" "$c"
32  rc=$?
33  unset GIT_INDEX_FILE; rm -f "$idx"
34  [ $rc -eq 0 ] && echo "$c"
35  return $rc
36}
37`;
38
39// ponytail: ties inside one second (same commit date) order by ref name, so "newest" is arbitrary only for sub-second turns
40// $1 = name (<epoch>-<turnId>), $2 = keep. Snapshot, then prune everything beyond the newest $2.
41const SNAPSHOT = `${PRELUDE}
42n=$(printf %s "$1" | tr -c 'A-Za-z0-9._-' '_')
43snap "$n" "claude checkpoint $n" || exit 1
44git for-each-ref --sort=-refname --sort=-committerdate --format='%(refname)' refs/claude-checkpoints \\
45  | tail -n +$(( $2 + 1 )) | while read -r r; do git update-ref -d "$r"; done
46`;
47
48const LIST = `${PRELUDE}
49w=$(now_tree)
50git for-each-ref --sort=-refname --sort=-committerdate --format='%(refname:strip=2) %(objectname) %(committerdate:relative)' refs/claude-checkpoints \\
51  | while read -r n o d; do s=$(git diff --shortstat "$o" "$w" | sed 's/^ //'); echo "$n | $d | \${s:-no changes}"; done
52`;
53
54// $1 = ref name or "" for the newest non-pre-undo one. Prints "<name> <sha>" or nothing.
55const RESOLVE = `
56if [ -n "$1" ]; then
57  o=$(git rev-parse -q --verify "refs/claude-checkpoints/$1^{commit}") && echo "$1 $o"
58else
59  git for-each-ref --sort=-refname --sort=-committerdate --format='%(refname:strip=2) %(objectname)' refs/claude-checkpoints | grep -v '^pre-undo-' | head -n 1
60fi
61`;
62
63// Files to delete on undo: untracked now (ignored files excluded) AND absent from the checkpoint.
64// Anything untracked before the turn is in the checkpoint tree, so it is never listed.
65// A third condition guards a changed .gitignore: the file must also be newer than the checkpoint commit.
66// ponytail: newline-separated names (a filename containing a newline is never removed, only skipped by accident of parsing)
67const VICTIMS = `
68victims() {
69  git -c core.quotepath=off ls-files -o --exclude-standard | sort > "$t/now"
70  git -c core.quotepath=off ls-tree -r --name-only "$o" | sort > "$t/then"
71  ct=$(git log -1 --format=%ct "$o")
72  d=$(date -r "$ct" +%Y%m%d%H%M.%S 2>/dev/null || date -d "@$ct" +%Y%m%d%H%M.%S) && touch -t "$d" "$t/stamp" || return 1
73  comm -23 "$t/now" "$t/then" | while IFS= read -r f; do [ "$f" -nt "$t/stamp" ] && echo "$f"; done
74}
75`;
76
77// $1 = ref name or "". Prints the preview: header line, diffstat, files that would be removed.
78const PREVIEW = `${PRELUDE}${VICTIMS}
79r=$(${RESOLVE}); [ -n "$r" ] || { echo NOCKPT; exit 0; }
80set -- $r; n=$1; o=$2; t=$(mktemp -d)
81echo "checkpoint $n"
82git diff --stat "$o" "$(now_tree)"
83echo "--- would remove (new since checkpoint):"
84victims
85rm -rf "$t"
86`;
87
88// $1 = ref name or "", $2 = epoch. Pre-undo snapshot, restore worktree, remove victims.
89const RESTORE = `${PRELUDE}${VICTIMS}
90r=$(${RESOLVE}); [ -n "$r" ] || { echo NOCKPT; exit 0; }
91set -- $r "$2"; n=$1; o=$2; ep=$3; t=$(mktemp -d)
92victims > "$t/victims"
93snap "pre-undo-$ep" "claude checkpoint pre-undo-$ep" >/dev/null || { echo "pre-undo snapshot failed, nothing changed"; exit 1; }
94# restore --worktree rewrites file contents only: index and HEAD stay as they are (checkout would stage).
95git restore --source="$o" --worktree -- . || { echo "restore failed (pre-undo-$ep holds your state)"; exit 1; }
96k=0; while IFS= read -r f; do [ -n "$f" ] && rm -f -- "$f" && k=$((k+1)); done < "$t/victims"
97rm -rf "$t"
98echo "restored $n, removed $k new file(s); to redo: /undo-turn pre-undo-$ep"
99`;
100
101function sh($, script, ...args) {
102  return $.process.run(["bash", "-c", script, "auto-checkpoint", ...args]);
103}
104
105async function epoch($) {
106  return String(Math.floor((await $.clock.now()) / 1000));
107}
108
109export function register(on, options = {}) {
110  const keep = Number.isInteger(options.keep) && options.keep > 0 ? options.keep : 20;
111  on("turn.start", async ($, e, next) => {
112    try {
113      // ponytail: every turn.start snapshots; if the host fires it for subagent turns too, filter here
114      await sh($, SNAPSHOT, `${await epoch($)}-${e.turnId}`, String(keep));
115    } catch {} // fail soft: a checkpoint failure never blocks the turn
116    return next(e);
117  });
118
119  on("session.start", async ($, e, next) => {
120    const result = await next(e);
121    try {
122      await $.command.register({ name: "checkpoints", description: "List auto-checkpoints and what changed since each." });
123      // immediate:false (omitted): must wait for the turn to end, never restore under a streaming turn
124      await $.command.register({ name: "undo-turn", description: "Restore the working tree to the latest checkpoint.", argumentHint: "[checkpoint-name]" });
125    } catch {} // name already taken
126    return result;
127  });
128
129  on("command.run", { command: "checkpoints" }, async ($) => {
130    try {
131      const r = await sh($, LIST);
132      if (r.stdout.startsWith("NOTREPO")) return { text: NOT_REPO };
133      return { text: r.stdout.trim() ? `name | age | changed vs now (tracked files)\n${r.stdout.trim()}` : "No checkpoints yet." };
134    } catch (err) {
135      return { text: NOT_REPO };
136    }
137  });
138
139  on("command.run", { command: "undo-turn" }, async ($, e) => {
140    const name = (e.args || "").trim().replace(/^refs\/claude-checkpoints\//, "");
141    try {
142      const p = await sh($, PREVIEW, name);
143      if (p.stdout.startsWith("NOTREPO")) return { text: NOT_REPO };
144      if (p.stdout.startsWith("NOCKPT")) return { text: "No checkpoint to restore." };
145      let answer;
146      try {
147        answer = await $.ui.ask(`Restore the working tree?\n${p.stdout.trim()}`, ["Restore", "Cancel"]);
148      } catch {
149        answer = "Cancel"; // dismissed or non-interactive
150      }
151      if (answer !== "Restore") return { text: "Undo cancelled. Nothing changed." };
152      const r = await sh($, RESTORE, name, await epoch($));
153      return { text: r.stdout.trim() || "Undo failed." };
154    } catch (err) {
155      return { text: "Undo failed, nothing changed." };
156    }
157  });
158}
159