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.

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.
turn.start, command.run and $.process.run driving plain git, $.command.register, and $.ui.ask as a confirmation gate before a destructive action.
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.
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.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
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
$: 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.git gc prunes pruned refs' objects.pre-undo-<epoch> name (the first is overwritten). Sub-second turns tie-break by ref name when pruning.bash script run by the mod, with the repo found from the session directory (nested repos and submodules are not snapshotted separately).AbovePrompt band.None.
hooks/auto-checkpoint.mjs 159 lines1// 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