Saves uncommitted work a moment before a command erases it. /save-point brings it back.

Saves uncommitted work a moment before a command erases it. /save-point brings it back.
git checkout -- . && git clean -fd
save-point 3: 7 files saved before git checkout · /save-point
Git has no undo for uncommitted work, and /rewind in Claude Code does not track changes made by shell commands. An agent that runs git checkout -- . to "start clean" takes your edits with it.
claude plugin install save-point@ground-rules
Needs Claude Code 2.1.287 or later and git on the PATH. See the repository README for updating and uninstalling.
classic.PreToolUse hook reads the Bash or PowerShell command and looks for:git checkout -- <paths>, git checkout ., git checkout -fgit restore, unless it is --staged onlygit reset --hard, git clean (except a dry run)git stash drop and git stash cleargit switch -f and git switch --discard-changesrm -r, Remove-Item -Recurse, rmdir /s, del /s, when a target is inside the repositoryIt follows cd and git -C to find the directory the command runs in, and uses the working directory Claude Code reports for the call.
classic.PostToolUse (and PostToolUseFailure) hook looks at whether the saved paths disappeared. If they did, the snapshot is kept, you get a toast, and Claude gets a note on the tool result: that command discarded 7 files of uncommitted work. It was saved first and the user can restore it with /save-point. Tell the user before doing anything else. If nothing was lost, the snapshot is dropped. A command that Claude Code or another hook blocks therefore leaves no record./save-point. Press Restore, read the files in the dialog, confirm. The mod first snapshots the current state the same way, so a restore can itself be undone, then writes the saved versions back.Digit 3 opens the pane from the band. It never restores anything by itself.
One commit object, built in a private index file inside .git (save-point-<session>.index):
git read-tree HEAD # first time only; later snapshots reuse the index
git add -A -- . <exclusions>
git write-tree
git commit-tree <tree> -p HEAD -m "save-point: <kind>"
It runs with core.autocrlf=false so that bytes are copied as they are: a restored file is byte-identical, including its line endings. It never touches the working tree, the real index, HEAD, a branch, a tag or the stash list, and creates no ref. A test checks that only these plumbing commands run.
Left out: untracked files over 20 MB, ignored files, and .env, .env.*, *.pem, *.key, *.pfx, *.p12 and id_*. The toast counts them. If a destructive command deletes an untracked .env, it is gone. Nothing here can save a secret.
For a stash, the snapshot is the stash commit. Restoring runs git stash apply <commit>.
On Windows with Git 2.55, a six-file round trip (three edits, two untracked files and a binary, then git checkout -- . && git clean -fd) came back 6 of 6 byte-identical, and refs, HEAD, the index and the stash list were unchanged. On a synthetic repository of 30,000 tracked files the first snapshot of a session took 3.0 s and the next five took 0.4 to 0.8 s. A real session, run with a headless Claude Code, saved both files, and Claude relayed the note.
None. GROUND_RULES_PRESENTATION=1 hides file and repository names on screen.
| Hooks | session.start, classic.PreToolUse, classic.PostToolUse, classic.PostToolUseFailure, command.run, and ui.render for the band and the pane. |
| Reads | The command text, git status, file sizes of untracked files. |
| Runs | git plumbing: rev-parse, status, read-tree, add, write-tree, commit-tree. On restore only: cat-file, diff --name-only, restore, stash apply. |
| Stores | sp:<session>:<n>: the snapshot commit, repository path, kind and file count. Records older than 15 days are deleted when a session starts. |
| Never | A denial, a rewrite of the command, a push, a ref. |
gc.pruneExpire, 14 days by default. The pane says so. A snapshot older than that may be gone, and the mod tells you instead of failing..gitattributes end-of-line rules can restore with normalised line endings.hooks/register.tsx 261 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending, Saved } from '../types'
5import { NOT_SAVED, bandText, claudeNote, isSaved, restoreQuestion, savedToast, snapshotLine } from './format'
6import { findDestructive, touchesRepo, type Hit } from './patterns'
7import { isPresentation } from './privacy'
8import {
9 applyStashes,
10 changedPaths,
11 exists,
12 filesToRestore,
13 locateRepo,
14 restoreFiles,
15 stashCommits,
16 takeSnapshot,
17 type Io,
18 type Repo,
19} from './snapshot'
20
21const PANE = 'save-point'
22const KEPT_PENDING = 20
23const KEPT_LISTED = 10
24const PRUNE_DAYS = 15
25const DAY = 86_400_000
26
27const pending = atom({ plugin: 'save-point', key: 'pending' } as const, {})
28const saved = atom({ plugin: 'save-point', key: 'saved' } as const, [])
29const count = atom({ plugin: 'save-point', key: 'count' } as const, 0)
30
31/** Snapshots run one at a time: two parallel tool calls would otherwise share one private index. */
32let queue: Promise<unknown> = Promise.resolve()
33let hasToldOutsideRepo = false
34
35/** What a snapshot may do to the machine: run git through `$.process.run`, and look at files. */
36function makeIo($: EngineInterface): Io {
37 return {
38 run: async (argv, init) => {
39 const result = await $.process.run(argv, init)
40 return { exitCode: result.exitCode, stdout: result.stdout }
41 },
42 sizeOf: async path => (await $.fs.stat(path)).size,
43 exists: path => $.fs.exists(path),
44 now: () => $.clock.now(),
45 }
46}
47
48const sessionTag = async ($: EngineInterface): Promise<string> => (await $.session.id()).replace(/[^A-Za-z0-9]/g, '').slice(0, 8)
49
50/** Takes the snapshot a destructive command is owed, and files it as pending under the call's id. */
51async function protect($: EngineInterface, callId: string, hits: Hit[], isWindows: boolean): Promise<void> {
52 const io = makeIo($)
53 const tag = await sessionTag($)
54 const attempted = new Set<string>()
55
56 for (const hit of hits) {
57 const repo = await locateRepo(io, hit.dir)
58 if (repo === undefined) {
59 if (hit.kind === 'recursive delete' && !hasToldOutsideRepo) {
60 hasToldOutsideRepo = true
61 $.ui.toast(NOT_SAVED['outside-repo'])
62 }
63 continue
64 }
65 // One snapshot covers every destructive command aimed at the same repository.
66 if (!touchesRepo(hit, repo.root, isWindows) || attempted.has(repo.root)) continue
67 attempted.add(repo.root)
68
69 const now = await $.clock.now()
70 const base = { kind: hit.kind, root: repo.root, gitDir: repo.gitDir, at: now }
71
72 if (hit.kind === 'git stash drop' || hit.kind === 'git stash clear') {
73 const stashes = await stashCommits(io, repo, hit.kind === 'git stash drop' ? (hit.args[1] ?? 'stash@{0}') : undefined)
74 if (stashes.length > 0) await update($, pending, held => keep(held, callId, { ...base, stashes, before: [], skipped: 0 }))
75 return
76 }
77
78 const outcome = await takeSnapshot(io, repo, hit.kind, tag)
79 if (!outcome.ok) {
80 if (outcome.reason !== 'clean') $.ui.toast(NOT_SAVED[outcome.reason === 'outside-repo' ? 'error' : outcome.reason])
81 continue
82 }
83 const { snapshot } = outcome
84 await update($, pending, held => keep(held, callId, { ...base, sha: snapshot.sha, before: snapshot.paths, skipped: snapshot.skipped.length }))
85 return
86 }
87}
88
89/** Adds one pending entry and drops the oldest past the cap. */
90function keep(held: Record<string, Pending>, id: string, entry: Pending): Record<string, Pending> {
91 const next = { ...held, [id]: entry }
92 const ids = Object.keys(next)
93 return ids.length <= KEPT_PENDING ? next : Object.fromEntries(Object.entries(next).slice(ids.length - KEPT_PENDING))
94}
95
96/** Stores a snapshot as saved and tells the person. */
97async function file($: EngineInterface, entry: Pending, fileCount: number): Promise<Saved> {
98 const number = (await read($, count)) + 1
99 await update($, count, () => number)
100 const key = `sp:${await $.session.id()}:${number}`
101 const record: Saved = {
102 key,
103 kind: entry.kind,
104 root: entry.root,
105 gitDir: entry.gitDir,
106 ...(entry.sha === undefined ? {} : { sha: entry.sha }),
107 ...(entry.stashes === undefined ? {} : { stashes: entry.stashes }),
108 fileCount,
109 skipped: entry.skipped,
110 at: entry.at,
111 }
112 await $.store.set(key, record)
113 await update($, saved, list => [record, ...list].slice(0, KEPT_LISTED))
114 $.ui.toast(savedToast(record, '3'))
115 return record
116}
117
118/** After the command: if it made saved paths disappear, keep the snapshot and tell Claude. */
119async function settle($: EngineInterface, callId: string): Promise<string[] | undefined> {
120 const entry = (await read($, pending))[callId]
121 if (entry === undefined) return undefined
122 await update($, pending, held => Object.fromEntries(Object.entries(held).filter(([id]) => id !== callId)))
123
124 const repo: Repo = { root: entry.root, gitDir: entry.gitDir }
125 const after = entry.stashes === undefined ? await changedPaths(makeIo($), repo) : []
126 const still = new Set((after ?? []).map(change => change.path))
127 const lost = entry.stashes === undefined ? entry.before.filter(path => !still.has(path)) : []
128 if (entry.stashes === undefined && lost.length === 0) return undefined
129
130 return [claudeNote(await file($, entry, lost.length))]
131}
132
133export const register: Register = on => {
134 let isWindows = false
135
136 on('session.start', async ($, e, next) => {
137 isWindows = (await $.env.get('OS')) === 'Windows_NT'
138 await $.command.register({ name: 'save-point', description: 'Show the saved snapshots and restore one', immediate: true })
139
140 // Snapshots older than git keeps unreferenced objects are gone; forget their records.
141 const now = await $.clock.now()
142 for (const key of (await $.store.keys()).filter(name => name.startsWith('sp:'))) {
143 const record = await $.store.get(key)
144 if (!isSaved(record) || now - record.at > PRUNE_DAYS * DAY) await $.store.delete(key)
145 }
146 return next(e)
147 })
148
149 on('classic.PreToolUse', async ($, e, next) => {
150 const shell = e.tool === 'Bash' ? 'bash' : e.tool === 'PowerShell' ? 'powershell' : undefined
151 if (shell === undefined || !('command' in e) || typeof e.command !== 'string') return next(e)
152
153 const hits = findDestructive(e.command, shell, await $.session.cwd(), isWindows)
154 if (hits.length > 0) {
155 queue = queue.catch(() => undefined).then(() => protect($, e.tool_use_id, hits, isWindows))
156 await queue.catch(() => undefined)
157 }
158 return next(e)
159 })
160
161 on('classic.PostToolUse', async ($, e, next) => {
162 const ran = await next(e)
163 const note = await settle($, e.tool_use_id)
164 return note === undefined ? ran : { ...ran, additionalContext: [...(ran.additionalContext ?? []), ...note] }
165 })
166
167 on('classic.PostToolUseFailure', async ($, e, next) => {
168 const ran = await next(e)
169 const note = await settle($, e.tool_use_id)
170 return note === undefined ? ran : { ...ran, additionalContext: [...(ran.additionalContext ?? []), ...note] }
171 })
172
173 on('command.run', { command: 'save-point' }, async $ => {
174 await $.ui.open({ id: PANE, title: 'save-point' })
175 return { text: 'save-point pane opened.' }
176 })
177
178 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
179 const below = await next(e)
180 const text = e.props.hasSurvey ? undefined : bandText((await read($, saved))[0], await $.clock.now())
181 if (text === undefined) return below
182
183 const { Box, Button, Text } = $.ui.resolve(e)
184 return (
185 <Box flexDirection="column">
186 {below}
187 <Box>
188 <Text dimColor>{text} </Text>
189 <Button key="open" plain hotkey="3" label="save-point" onPress={() => $.ui.open({ id: PANE, title: 'save-point' })} />
190 </Box>
191 </Box>
192 )
193 })
194
195 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196 const { Box, Button, Text } = $.ui.resolve(e)
197 const now = await $.clock.now()
198 const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
199 const records: Saved[] = []
200 for (const key of (await $.store.keys()).filter(name => name.startsWith('sp:'))) {
201 const record = await $.store.get(key)
202 if (isSaved(record)) records.push(record)
203 }
204 const listed = records.sort((a, b) => b.at - a.at).slice(0, KEPT_LISTED)
205
206 const restore = (record: Saved) => async (): Promise<void> => {
207 const io = makeIo($)
208 const repo: Repo = { root: record.root, gitDir: record.gitDir }
209 const target = record.sha ?? record.stashes?.[0]
210 if (target === undefined || !(await exists(io, repo, target))) {
211 $.ui.toast('save-point: that snapshot no longer exists (git gc removed it).')
212 return
213 }
214
215 const paths = record.sha === undefined ? [] : await filesToRestore(io, repo, record.sha)
216 if (record.sha !== undefined && paths.length === 0) {
217 $.ui.toast('save-point: nothing to restore, the files already match the snapshot.')
218 return
219 }
220
221 const answer = await $.ui.ask(restoreQuestion(record, paths, isPresenting), ['Keep', 'Restore'])
222 if (answer !== 'Restore') return
223
224 // Take the current state first, so a restore can be undone the same way.
225 const fresh = await takeSnapshot(io, repo, 'before restore', await sessionTag($))
226 if (fresh.ok) {
227 const { snapshot } = fresh
228 const entry: Pending = {
229 kind: 'before restore',
230 root: repo.root,
231 gitDir: repo.gitDir,
232 sha: snapshot.sha,
233 before: snapshot.paths,
234 skipped: snapshot.skipped.length,
235 at: await $.clock.now(),
236 }
237 await file($, entry, snapshot.paths.length)
238 }
239
240 const isDone =
241 record.sha === undefined
242 ? await applyStashes(io, repo, record.stashes ?? [])
243 : await restoreFiles(io, repo, record.sha, paths)
244 $.ui.toast(isDone ? 'save-point: restored.' : 'save-point: the restore stopped partway. Run git status.')
245 }
246
247 return (
248 <Box flexDirection="column">
249 {listed.length === 0 && <Text dimColor>Nothing has been saved yet.</Text>}
250 {listed.map(record => (
251 <Box key={`row-${record.key}`}>
252 <Text>{snapshotLine(record, now, isPresenting)} </Text>
253 <Button key={`restore-${record.key}`} label="Restore" onPress={restore(record)} />
254 </Box>
255 ))}
256 <Text dimColor>A snapshot is a git object with no ref. git gc removes it after about 14 days.</Text>
257 </Box>
258 )
259 })
260}
261hooks/format.ts 82 lines1import type { Saved } from '../types'
2
3/** A stored value that has the fields of a saved snapshot; records from another version fail the check. */
4export function isSaved(value: unknown): value is Saved {
5 return (
6 typeof value === 'object' &&
7 value !== null &&
8 'key' in value &&
9 typeof value.key === 'string' &&
10 'kind' in value &&
11 typeof value.kind === 'string' &&
12 'root' in value &&
13 typeof value.root === 'string' &&
14 'gitDir' in value &&
15 typeof value.gitDir === 'string' &&
16 'at' in value &&
17 typeof value.at === 'number' &&
18 'fileCount' in value &&
19 typeof value.fileCount === 'number'
20 )
21}
22
23const MINUTE = 60_000
24const HOUR = 60 * MINUTE
25const LISTED_FILES = 8
26const BAND_MINUTES = 30
27
28/** `45s`, `12m`, `3h`, `2d`. */
29export function ago(ms: number): string {
30 if (ms < MINUTE) return `${Math.max(0, Math.floor(ms / 1000))}s`
31 if (ms < HOUR) return `${Math.floor(ms / MINUTE)}m`
32 if (ms < 24 * HOUR) return `${Math.floor(ms / HOUR)}h`
33 return `${Math.floor(ms / (24 * HOUR))}d`
34}
35
36const files = (count: number): string => `${count} file${count === 1 ? '' : 's'}`
37
38/** The toast after a command destroyed work that was saved first. */
39export function savedToast(saved: Saved, digit: string): string {
40 const skipped = saved.skipped === 0 ? '' : ` (${saved.skipped} secret or large file${saved.skipped === 1 ? '' : 's'} not saved)`
41 const what = saved.kind.startsWith('git stash') ? `stashed work saved before ${saved.kind}` : `${files(saved.fileCount)} saved before ${saved.kind}`
42 return `save-point ${digit}: ${what}${skipped} · /save-point`
43}
44
45/** The note Claude reads next to the result of the command that destroyed work. */
46export function claudeNote(saved: Saved): string {
47 const what = saved.kind.startsWith('git stash') ? 'a stash' : `${files(saved.fileCount)} of uncommitted work`
48 return `save-point: that command discarded ${what}. It was saved first and the user can restore it with /save-point. Tell the user before doing anything else.`
49}
50
51/** The row above the prompt while the newest snapshot is recent, or undefined. */
52export function bandText(saved: Saved | undefined, now: number): string | undefined {
53 if (saved === undefined || now - saved.at > BAND_MINUTES * MINUTE) return undefined
54 const what = saved.kind.startsWith('git stash') ? 'stash saved' : `${files(saved.fileCount)} saved`
55 return `save-point · ${what} before ${saved.kind} · ${ago(now - saved.at)} ago`
56}
57
58/** The pane's line for one snapshot. */
59export function snapshotLine(saved: Saved, now: number, isPresenting: boolean): string {
60 const where = isPresenting ? '' : ` · ${saved.root.split(/[\\/]/).filter(part => part !== '').at(-1) ?? ''}`
61 const count = saved.kind.startsWith('git stash') ? 'stash' : files(saved.fileCount)
62 return `${ago(now - saved.at)} ago · ${saved.kind} · ${count}${where}`
63}
64
65/** The question the restore button asks, with the files it will write. */
66export function restoreQuestion(saved: Saved, paths: readonly string[], isPresenting: boolean): string {
67 if (saved.kind.startsWith('git stash')) {
68 return `Re-apply ${saved.stashes?.length ?? 0} stashed change${saved.stashes?.length === 1 ? '' : 's'}? Your current work is saved first.`
69 }
70
71 const listed = isPresenting
72 ? ''
73 : ` ${paths.slice(0, LISTED_FILES).join(', ')}${paths.length > LISTED_FILES ? `, and ${paths.length - LISTED_FILES} more` : ''}.`
74 return `Restore ${files(paths.length)} from before ${saved.kind}? Your current versions are saved first.${listed}`
75}
76
77export const NOT_SAVED: Record<'outside-repo' | 'timeout' | 'error', string> = {
78 'outside-repo': 'save-point: outside a repo, not saved.',
79 timeout: 'save-point: no snapshot (timed out). The command runs anyway.',
80 error: 'save-point: could not take a snapshot. The command runs anyway.',
81}
82hooks/patterns.ts 187 lines1import { splitSegments, splitWords, type Shell, type Word } from './scan'
2
3/** The commands that throw uncommitted work away. */
4export type Kind =
5 | 'git checkout'
6 | 'git restore'
7 | 'git reset --hard'
8 | 'git clean'
9 | 'git stash drop'
10 | 'git stash clear'
11 | 'git switch --discard-changes'
12 | 'recursive delete'
13
14export type Hit = {
15 kind: Kind
16 /** The directory the command runs in, after any `cd` or `git -C` before it. */
17 dir: string
18 /** The paths a recursive delete names, as written; empty for the git kinds. */
19 targets: string[]
20 /** The words after a git subcommand that are not options: `stash@{1}` of `git stash drop stash@{1}`. */
21 args: string[]
22}
23
24const BASH_CD = new Set(['cd', 'pushd'])
25const POWERSHELL_CD = new Set(['cd', 'sl', 'chdir', 'pushd', 'set-location', 'push-location'])
26const POWERSHELL_DELETE = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rd', 'rmdir'])
27const CMD_DELETE = new Set(['rmdir', 'rd', 'del', 'erase'])
28/** Git options that take the next word as their value. */
29const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
30
31const MSYS_DRIVE = /^\/([A-Za-z])\/(.*)$/
32const DRIVE = /^[A-Za-z]:[\\/]/
33
34const isAbsolute = (path: string): boolean => DRIVE.test(path) || path.startsWith('/') || path.startsWith('\\\\')
35
36/** `base` joined with `path`, `.` and `..` folded, in forward slashes; Git Bash's `/d/x` becomes `D:/x` on Windows. */
37export function resolvePath(base: string, path: string, isWindows: boolean): string {
38 const msys = isWindows ? MSYS_DRIVE.exec(path) : null
39 const given = (msys ? `${(msys[1] as string).toUpperCase()}:/${msys[2]}` : path).replace(/\\/g, '/')
40 const full = isAbsolute(given) ? given : `${base.replace(/\\/g, '/').replace(/\/+$/, '')}/${given}`
41
42 const root = /^[A-Za-z]:/.test(full) ? full.slice(0, 2) : full.startsWith('//') ? '//' : ''
43 const parts: string[] = []
44 for (const part of full.slice(root.length).split('/')) {
45 if (part === '' || part === '.') continue
46 if (part === '..') parts.pop()
47 else parts.push(part)
48 }
49 return `${root}/${parts.join('/')}`.replace(/^\/\/\//, '//').replace(/^([A-Za-z]:)\/$/, '$1/')
50}
51
52const hasShortFlag = (words: Word[], letters: string): boolean =>
53 words.some(word => /^-[A-Za-z]+$/.test(word.value) && [...word.value.slice(1)].some(char => letters.includes(char)))
54
55const hasWord = (words: Word[], ...values: string[]): boolean => words.some(word => values.includes(word.value))
56
57type GitCall = { dir?: string; sub: string; args: Word[] }
58
59/** The subcommand of a git call, its arguments, and the folder `-C` points at. */
60function parseGit(args: Word[]): GitCall | undefined {
61 let dir: string | undefined
62 for (let i = 0; i < args.length; i += 1) {
63 const word = args[i] as Word
64 if (word.value === '-C') {
65 dir = args[i + 1]?.value
66 i += 1
67 } else if (GIT_VALUE_OPTIONS.has(word.value)) {
68 i += 1
69 } else if (!word.value.startsWith('-')) {
70 return { ...(dir === undefined ? {} : { dir }), sub: word.value, args: args.slice(i + 1) }
71 }
72 }
73 return undefined
74}
75
76function gitKind({ sub, args }: GitCall): Kind | undefined {
77 const flags = args.filter(arg => arg.value.startsWith('-'))
78 const plain = args.filter(arg => !arg.value.startsWith('-'))
79
80 switch (sub) {
81 case 'checkout': {
82 const separator = args.findIndex(arg => arg.value === '--')
83 const isPathCheckout = separator !== -1 && separator < args.length - 1
84 return isPathCheckout || plain.some(arg => arg.value === '.') || hasWord(flags, '-f', '--force') ? 'git checkout' : undefined
85 }
86 case 'restore': {
87 const isIndexOnly = hasWord(flags, '--staged', '-S') && !hasWord(flags, '--worktree', '-W')
88 return isIndexOnly ? undefined : 'git restore'
89 }
90 case 'reset':
91 return hasWord(flags, '--hard') ? 'git reset --hard' : undefined
92 case 'clean':
93 return hasWord(flags, '--dry-run') || hasShortFlag(flags, 'n') ? undefined : 'git clean'
94 case 'stash':
95 return plain[0]?.value === 'drop' ? 'git stash drop' : plain[0]?.value === 'clear' ? 'git stash clear' : undefined
96 case 'switch':
97 return hasWord(flags, '--discard-changes', '-f', '--force') ? 'git switch --discard-changes' : undefined
98 default:
99 return undefined
100 }
101}
102
103const isRecursive = (flags: Word[]): boolean =>
104 hasWord(flags, '--recursive') || hasShortFlag(flags, 'rR') || flags.some(flag => /^-r(e(c(u(r(s(e)?)?)?)?)?)?$/i.test(flag.value))
105
106/** The delete command's targets, and whether it deletes recursively. */
107function deleteCall(shell: Shell, name: string, args: Word[]): { targets: string[] } | undefined {
108 const flags = args.filter(arg => arg.value.startsWith('-') || /^\/[A-Za-z]$/.test(arg.value))
109 const isCmdRecursive = flags.some(flag => flag.value.toLowerCase() === '/s')
110
111 const isDelete =
112 shell === 'bash' ? name === 'rm' && isRecursive(flags) : (POWERSHELL_DELETE.has(name) && isRecursive(flags)) || (CMD_DELETE.has(name) && isCmdRecursive)
113 if (!isDelete) return undefined
114
115 const targets: string[] = []
116 for (let i = 0; i < args.length; i += 1) {
117 const word = args[i] as Word
118 if (/^-(path|literalpath)$/i.test(word.value)) {
119 const target = args[i + 1]
120 if (target) targets.push(target.raw)
121 i += 1
122 } else if (!flags.includes(word)) {
123 targets.push(word.raw)
124 }
125 }
126 return { targets }
127}
128
129function cdTarget(words: Word[], shell: Shell): string | undefined {
130 const [command, ...args] = words
131 if (!command || !(shell === 'bash' ? BASH_CD : POWERSHELL_CD).has(shell === 'bash' ? command.value : command.value.toLowerCase())) return undefined
132
133 const positional = args.filter(arg => !arg.value.startsWith('-') && arg.value.toLowerCase() !== '/d')
134 const [target] = positional
135 return positional.length === 1 && target && target.isStatic && target.value !== '-' ? target.value : undefined
136}
137
138/** The destructive commands in `command`, each with the directory it runs in. */
139export function findDestructive(command: string, shell: Shell, cwd: string, isWindows: boolean): Hit[] {
140 const hits: Hit[] = []
141 let dir = cwd
142
143 for (const segment of splitSegments(command, shell)) {
144 const words = splitWords(segment.text, shell)
145 const target = cdTarget(words, shell)
146 if (target !== undefined) {
147 dir = resolvePath(dir, target, isWindows)
148 continue
149 }
150
151 const [head, ...args] = words
152 if (!head) continue
153 const name = (shell === 'bash' ? head.value : head.value.toLowerCase()).split(/[\\/]/).at(-1)?.replace(/\.exe$/, '') ?? ''
154
155 if (name === 'git') {
156 const call = parseGit(args)
157 const kind = call && gitKind(call)
158 if (call && kind) {
159 const where = call.dir === undefined ? dir : resolvePath(dir, call.dir, isWindows)
160 hits.push({ kind, dir: where, targets: [], args: call.args.filter(arg => !arg.value.startsWith('-')).map(arg => arg.value) })
161 }
162 continue
163 }
164
165 const removal = deleteCall(shell, name, args)
166 if (removal) hits.push({ kind: 'recursive delete', dir, targets: removal.targets, args: [] })
167 }
168
169 return hits
170}
171
172const unquote = (raw: string): string => raw.replace(/^(['"])(.*)\1$/, '$2')
173
174/** False for a recursive delete whose every target is outside `root`; true for any other hit, and when a target is dynamic. */
175export function touchesRepo(hit: Hit, root: string, isWindows: boolean): boolean {
176 if (hit.kind !== 'recursive delete' || hit.targets.length === 0) return true
177
178 const base = root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
179 return hit.targets.some(raw => {
180 const target = unquote(raw)
181 if (/[$`]/.test(target)) return true
182
183 const full = resolvePath(hit.dir, target, isWindows).toLowerCase()
184 return full === base || full.startsWith(`${base}/`)
185 })
186}
187hooks/privacy.ts 28 lines1// What may reach the screen. Descriptions are Claude's own words about a
2// command and can carry a client name, a path or a token, so every one is cut
3// and scrubbed, and `GROUND_RULES_PRESENTATION=1` replaces them with the kind.
4
5const LABEL_LIMIT = 28
6
7/** Long runs of token-like characters and URL credentials become an ellipsis. */
8export function scrub(text: string): string {
9 return text
10 .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]*@/gi, '')
11 .replace(/[A-Za-z0-9_\-+/=]{24,}/g, '…')
12 .replace(/\b[A-Za-z]:[\\/][^\s"']*/g, '…')
13 .replace(/(^|\s)\/[^\s"']+/g, '$1…')
14 .replace(/\s+/g, ' ')
15 .trim()
16}
17
18/** A task description as it may appear on screen. */
19export function label(description: string, kind: string, isPresenting: boolean): string {
20 if (isPresenting) return kind
21 const clean = scrub(description)
22 if (clean === '') return kind
23 return clean.length > LABEL_LIMIT ? `${clean.slice(0, LABEL_LIMIT - 1).trimEnd()}…` : clean
24}
25
26/** True when the presentation switch is set to the literal `1`. */
27export const isPresentation = (value: string | undefined): boolean => value === '1'
28hooks/snapshot.ts 192 lines1// Takes and restores a snapshot of uncommitted work with plain git plumbing.
2// A snapshot is one commit object built in a private index file: it never
3// touches the working tree, the real index, HEAD or any ref. This file has no
4// imports from the plugin, so a script can run it against a real repository.
5
6export type RunInit = { cwd?: string; env?: Record<string, string>; timeoutMs?: number }
7export type RunResult = { exitCode: number; stdout: string }
8
9/** What the snapshot may do to the machine: run git, and ask about a file. */
10export type Io = {
11 run: (argv: readonly string[], init?: RunInit) => Promise<RunResult>
12 sizeOf: (path: string) => Promise<number>
13 exists: (path: string) => Promise<boolean>
14 now: () => Promise<number>
15}
16
17export type Repo = { root: string; gitDir: string }
18
19export type Snapshot = {
20 /** The snapshot commit. */
21 sha: string
22 repo: Repo
23 /** Changed paths at the moment of the snapshot, for loss detection; secrets and big files left out. */
24 paths: string[]
25 /** Changed paths the snapshot left out: secrets and untracked files over 20 MB. */
26 skipped: string[]
27}
28
29export type Failure = 'outside-repo' | 'clean' | 'timeout' | 'error'
30
31export type Outcome = { ok: true; snapshot: Snapshot } | { ok: false; reason: Failure }
32
33export const SNAPSHOT_BUDGET_MS = 15_000
34export const MAX_UNTRACKED_BYTES = 20 * 1024 * 1024
35
36/** Names whose contents are never copied into a snapshot. */
37export const SECRET_NAMES: readonly RegExp[] = [/(^|\/)\.env(\.[^/]*)?$/, /\.(pem|key|pfx|p12)$/i, /(^|\/)id_[^/]+$/]
38const SECRET_PATHSPECS = ['**/.env', '**/.env.*', '**/*.pem', '**/*.key', '**/*.pfx', '**/*.p12', '**/id_*']
39
40const IDENTITY = {
41 GIT_AUTHOR_NAME: 'save-point',
42 GIT_AUTHOR_EMAIL: 'save-point@localhost',
43 GIT_COMMITTER_NAME: 'save-point',
44 GIT_COMMITTER_EMAIL: 'save-point@localhost',
45}
46
47/** Options that make git copy bytes as they are, so a restore returns a file with its own line endings. */
48const VERBATIM = ['-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false']
49
50const isSecret = (path: string): boolean => SECRET_NAMES.some(pattern => pattern.test(path))
51
52/** The changed paths in `git status --porcelain -z` output, with the untracked ones marked. */
53export function parseStatus(stdout: string): Array<{ path: string; isUntracked: boolean }> {
54 const entries = stdout.split('\0')
55 const changes: Array<{ path: string; isUntracked: boolean }> = []
56
57 for (let i = 0; i < entries.length; i += 1) {
58 const entry = entries[i] as string
59 if (entry.length < 4) continue
60
61 changes.push({ path: entry.slice(3), isUntracked: entry.startsWith('??') })
62 // A rename or copy lists its source path as the next entry.
63 if (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C') i += 1
64 }
65 return changes
66}
67
68/** Splits `paths` into batches that fit a command line. */
69export function batches(paths: readonly string[], size = 200): string[][] {
70 const out: string[][] = []
71 for (let i = 0; i < paths.length; i += size) out.push(paths.slice(i, i + size))
72 return out
73}
74
75export async function locateRepo(io: Io, dir: string): Promise<Repo | undefined> {
76 const result = await io.run(['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd: dir, timeoutMs: 5_000 }).catch(() => undefined)
77 if (result === undefined || result.exitCode !== 0) return undefined
78
79 const [root, gitDir] = result.stdout.split(/\r?\n/)
80 return root && gitDir ? { root, gitDir } : undefined
81}
82
83/** Changed paths in the working tree, without taking the index lock. */
84export async function changedPaths(io: Io, repo: Repo, timeoutMs = 10_000): Promise<Array<{ path: string; isUntracked: boolean }> | undefined> {
85 const result = await io
86 .run(['git', 'status', '--porcelain', '-z', '--untracked-files=all'], { cwd: repo.root, env: { GIT_OPTIONAL_LOCKS: '0' }, timeoutMs })
87 .catch(() => undefined)
88 return result === undefined || result.exitCode !== 0 ? undefined : parseStatus(result.stdout)
89}
90
91/** Untracked files over the size limit and every secret among the changes. */
92async function leftOut(io: Io, repo: Repo, changes: Array<{ path: string; isUntracked: boolean }>): Promise<string[]> {
93 const skipped: string[] = []
94 for (const change of changes) {
95 if (isSecret(change.path)) {
96 skipped.push(change.path)
97 continue
98 }
99 if (!change.isUntracked) continue
100
101 const size = await io.sizeOf(`${repo.root}/${change.path}`).catch(() => 0)
102 if (size > MAX_UNTRACKED_BYTES) skipped.push(change.path)
103 }
104 return skipped
105}
106
107/**
108 * Saves the working tree's uncommitted changes as one commit object. It
109 * writes objects into the repository and one private index file, nothing else.
110 */
111export async function takeSnapshot(io: Io, repo: Repo, label: string, tag: string): Promise<Outcome> {
112 const started = await io.now()
113 const remaining = async (): Promise<number> => SNAPSHOT_BUDGET_MS - ((await io.now()) - started)
114 const git = async (argv: readonly string[], env: Record<string, string>): Promise<RunResult | undefined> => {
115 const left = await remaining()
116 if (left <= 0) return undefined
117 return io.run(['git', ...VERBATIM, ...argv], { cwd: repo.root, env, timeoutMs: left }).catch(() => undefined)
118 }
119
120 const fail = async (): Promise<Outcome> => ({ ok: false, reason: (await remaining()) <= 0 ? 'timeout' : 'error' })
121
122 const changes = await changedPaths(io, repo, Math.max(1_000, await remaining()))
123 if (changes === undefined) return fail()
124 if (changes.length === 0) return { ok: false, reason: 'clean' }
125
126 const skipped = await leftOut(io, repo, changes)
127 const indexPath = `${repo.gitDir}/save-point-${tag}.index`
128 const index = { GIT_INDEX_FILE: indexPath, ...IDENTITY }
129 const head = await git(['rev-parse', '--verify', '-q', 'HEAD'], {})
130 const hasHead = head?.exitCode === 0
131
132 const excludes = [
133 ...SECRET_PATHSPECS.map(pattern => `:(exclude,glob)${pattern}`),
134 ...skipped.filter(path => !isSecret(path)).map(path => `:(exclude,literal)${path}`),
135 ]
136 // `add -A` brings the private index to the working tree whatever it held, so a later snapshot reuses
137 // it: its cached file stats make the scan of a large repository about a tenth as slow.
138 const seed: Array<readonly string[]> = (await io.exists(indexPath)) ? [] : [hasHead ? ['read-tree', 'HEAD'] : ['read-tree', '--empty']]
139 const steps: Array<readonly string[]> = [...seed, ['add', '-A', '--', '.', ...excludes]]
140 for (const step of steps) {
141 if ((await git(step, index))?.exitCode !== 0) return fail()
142 }
143
144 const tree = await git(['write-tree'], index)
145 if (tree?.exitCode !== 0) return fail()
146
147 const commit = await git(['commit-tree', tree.stdout.trim(), ...(hasHead ? ['-p', 'HEAD'] : []), '-m', `save-point: ${label}`], index)
148 const sha = commit?.stdout.trim()
149 if (commit?.exitCode !== 0 || !sha) return fail()
150
151 const paths = changes.map(change => change.path).filter(path => !skipped.includes(path))
152 return { ok: true, snapshot: { sha, repo, paths, skipped } }
153}
154
155/** The stash commits `git stash` holds, newest first, or just `stash@{n}` when one is named. */
156export async function stashCommits(io: Io, repo: Repo, named?: string): Promise<string[]> {
157 const argv = named === undefined ? ['git', 'stash', 'list', '--format=%H'] : ['git', 'rev-parse', '--verify', '-q', named]
158 const result = await io.run(argv, { cwd: repo.root, timeoutMs: 5_000 }).catch(() => undefined)
159 if (result === undefined || result.exitCode !== 0) return []
160 return result.stdout.split(/\r?\n/).filter(line => /^[0-9a-f]{40,64}$/.test(line))
161}
162
163/** True when the object still exists: `git gc` prunes unreferenced objects after about two weeks. */
164export async function exists(io: Io, repo: Repo, sha: string): Promise<boolean> {
165 const result = await io.run(['git', 'cat-file', '-e', `${sha}^{commit}`], { cwd: repo.root, timeoutMs: 5_000 }).catch(() => undefined)
166 return result?.exitCode === 0
167}
168
169/** Files whose working-tree content differs from the snapshot: what a restore would write. */
170export async function filesToRestore(io: Io, repo: Repo, sha: string): Promise<string[]> {
171 const result = await io.run(['git', ...VERBATIM, 'diff', '--name-only', '-z', sha, '--'], { cwd: repo.root, timeoutMs: 15_000 }).catch(() => undefined)
172 return result === undefined || result.exitCode !== 0 ? [] : result.stdout.split('\0').filter(path => path !== '')
173}
174
175/** Writes the snapshot's version of `files` into the working tree. True when every batch succeeded. */
176export async function restoreFiles(io: Io, repo: Repo, sha: string, files: readonly string[]): Promise<boolean> {
177 for (const batch of batches(files)) {
178 const result = await io.run(['git', ...VERBATIM, 'restore', '--source', sha, '--worktree', '--', ...batch], { cwd: repo.root, timeoutMs: 30_000 }).catch(() => undefined)
179 if (result === undefined || result.exitCode !== 0) return false
180 }
181 return true
182}
183
184/** Re-applies stash commits, oldest first. True when each applied cleanly. */
185export async function applyStashes(io: Io, repo: Repo, shas: readonly string[]): Promise<boolean> {
186 for (const sha of [...shas].reverse()) {
187 const result = await io.run(['git', 'stash', 'apply', sha], { cwd: repo.root, timeoutMs: 30_000 }).catch(() => undefined)
188 if (result === undefined || result.exitCode !== 0) return false
189 }
190 return true
191}
192hooks/scan.ts 261 lines1// A small shell scanner: it splits a command into top-level segments and a
2// segment into words. It understands only what the cd-chain check needs:
3// quotes, here-documents, comments, command substitution and line
4// continuations in Bash and PowerShell. It never evaluates anything.
5
6export type Shell = 'bash' | 'powershell'
7
8/** What ended a segment. A newline counts as `;`. */
9export type Separator = '&&' | '||' | '|' | ';' | '&'
10
11export type Segment = {
12 text: string
13 separator: Separator
14}
15
16export type Word = {
17 /** The word as written, quotes included. */
18 raw: string
19 /** The word with quotes removed. Backslashes stay, so Windows paths survive. */
20 value: string
21 /** False when the shell would expand the word (`$x`, `$(…)`, a backtick). */
22 isStatic: boolean
23}
24
25const isSpace = (char: string | undefined): boolean =>
26 char === ' ' || char === '\t' || char === '\n'
27
28const escapeChar = (shell: Shell): string => (shell === 'bash' ? '\\' : '`')
29
30/** Index just past the quote that opens at `start`, or the end of input. */
31export function skipQuoted(text: string, start: number, shell: Shell): number {
32 const quote = text[start]
33 let i = start + 1
34 while (i < text.length) {
35 const char = text[i]
36 if (quote === '"' && char === escapeChar(shell)) {
37 i += 2
38 } else if (char === quote) {
39 // PowerShell writes a quote inside single quotes as two.
40 if (shell === 'powershell' && quote === "'" && text[i + 1] === "'") {
41 i += 2
42 } else {
43 return i + 1
44 }
45 } else {
46 i += 1
47 }
48 }
49 return text.length
50}
51
52/** Index just past the `$(…)` that opens at `start`, parentheses nested. */
53function skipSubstitution(text: string, start: number, shell: Shell): number {
54 let depth = 0
55 let i = start + 1
56 while (i < text.length) {
57 const char = text[i]
58 if (char === "'" || char === '"') {
59 i = skipQuoted(text, i, shell)
60 continue
61 }
62 if (char === '(') depth += 1
63 if (char === ')') {
64 depth -= 1
65 if (depth < 0) return i + 1
66 }
67 i += 1
68 }
69 return text.length
70}
71
72/** Index just past a Bash `<<WORD` operator, and the delimiter it names. */
73function readHeredocOperator(
74 text: string,
75 start: number,
76): { end: number; delimiter: string } | undefined {
77 let i = start + 2
78 if (text[i] === '<') return undefined
79 if (text[i] === '-') i += 1
80 while (text[i] === ' ' || text[i] === '\t') i += 1
81 const quote = text[i] === "'" || text[i] === '"' ? text[i] : undefined
82 if (quote) i += 1
83 const from = i
84 while (i < text.length && !isSpace(text[i]) && text[i] !== quote && !';&|()<>'.includes(text[i] ?? '')) {
85 i += 1
86 }
87 const delimiter = text.slice(from, i)
88 if (delimiter === '') return undefined
89 return { end: quote ? i + 1 : i, delimiter }
90}
91
92/** Index of the line after the one that holds only `delimiter`. */
93function skipHeredocBody(text: string, from: number, delimiter: string): number {
94 let lineStart = from
95 while (lineStart < text.length) {
96 const lineEnd = text.indexOf('\n', lineStart)
97 const end = lineEnd === -1 ? text.length : lineEnd
98 if (text.slice(lineStart, end).trim() === delimiter) return end
99 if (lineEnd === -1) return text.length
100 lineStart = lineEnd + 1
101 }
102 return text.length
103}
104
105/** Index just past a PowerShell here-string that opens at `start` (`@'` or `@"`). */
106function skipHereString(text: string, start: number): number {
107 const closer = `\n${text[start + 1]}@`
108 const end = text.indexOf(closer, start)
109 return end === -1 ? text.length : end + closer.length
110}
111
112function isHereStringOpen(text: string, i: number): boolean {
113 if (text[i] !== '@' || (text[i + 1] !== "'" && text[i + 1] !== '"')) return false
114 let j = i + 2
115 while (text[j] === ' ' || text[j] === '\t') j += 1
116 return text[j] === '\n'
117}
118
119/**
120 * Splits `source` at the top-level `&&`, `||`, `|`, `;`, `&` and newlines.
121 * Text inside quotes, `$(…)`, here-documents, here-strings and comments never
122 * splits a segment, and comments and here-document bodies are dropped.
123 */
124export function splitSegments(source: string, shell: Shell): Segment[] {
125 const text = source.replace(/\r\n?/g, '\n')
126 const segments: Segment[] = []
127 const heredocs: string[] = []
128 let current = ''
129 let i = 0
130
131 const end = (separator: Separator): void => {
132 segments.push({ text: current.trim(), separator })
133 current = ''
134 }
135
136 while (i < text.length) {
137 const char = text[i] as string
138 const next = text[i + 1]
139 const isWordStart = current === '' || isSpace(current.at(-1))
140
141 if (char === "'" || char === '"') {
142 const stop = skipQuoted(text, i, shell)
143 current += text.slice(i, stop)
144 i = stop
145 } else if (char === '$' && next === '(') {
146 const stop = skipSubstitution(text, i + 1, shell)
147 current += text.slice(i, stop)
148 i = stop
149 } else if (char === '`' && shell === 'bash') {
150 const close = text.indexOf('`', i + 1)
151 const stop = close === -1 ? text.length : close + 1
152 current += text.slice(i, stop)
153 i = stop
154 } else if (char === escapeChar(shell)) {
155 if (next === '\n') {
156 current += ' '
157 i += 2
158 } else {
159 current += text.slice(i, i + 2)
160 i += 2
161 }
162 } else if (shell === 'powershell' && isHereStringOpen(text, i)) {
163 const stop = skipHereString(text, i)
164 current += '""'
165 i = stop
166 } else if (shell === 'powershell' && char === '<' && next === '#') {
167 const close = text.indexOf('#>', i + 2)
168 i = close === -1 ? text.length : close + 2
169 } else if (char === '#' && isWordStart) {
170 const newline = text.indexOf('\n', i)
171 i = newline === -1 ? text.length : newline
172 } else if (shell === 'bash' && char === '<' && next === '<') {
173 const heredoc = readHeredocOperator(text, i)
174 if (heredoc) {
175 heredocs.push(heredoc.delimiter)
176 current += text.slice(i, heredoc.end)
177 i = heredoc.end
178 } else {
179 current += '<<'
180 i += 2
181 }
182 } else if (char === '\n') {
183 end(';')
184 i += 1
185 for (const delimiter of heredocs.splice(0)) {
186 i = Math.min(text.length, skipHeredocBody(text, i, delimiter) + 1)
187 }
188 } else if (char === '&' && next === '&') {
189 end('&&')
190 i += 2
191 } else if (char === '|' && next === '|') {
192 end('||')
193 i += 2
194 } else if (char === '|') {
195 end('|')
196 i += 1
197 } else if (char === ';') {
198 end(';')
199 i += 1
200 } else if (char === '&') {
201 // `2>&1`, `>&2` and `&>` are redirections, not backgrounding.
202 if (text[i - 1] === '>' || text[i - 1] === '<' || next === '>') {
203 current += char
204 } else {
205 end('&')
206 }
207 i += 1
208 } else if (shell === 'bash' && char === '(' && current.trim() === '') {
209 i += 1
210 } else if (shell === 'bash' && char === ')') {
211 end(';')
212 i += 1
213 } else {
214 current += char
215 i += 1
216 }
217 }
218
219 end(';')
220 return segments
221}
222
223/** Splits one segment into words, quotes honoured. */
224export function splitWords(segment: string, shell: Shell): Word[] {
225 const words: Word[] = []
226 let raw = ''
227 let value = ''
228 let isStatic = true
229 let i = 0
230
231 const flush = (): void => {
232 if (raw !== '') words.push({ raw, value, isStatic })
233 raw = ''
234 value = ''
235 isStatic = true
236 }
237
238 while (i < segment.length) {
239 const char = segment[i] as string
240 if (isSpace(char)) {
241 flush()
242 i += 1
243 } else if (char === "'" || char === '"') {
244 const stop = skipQuoted(segment, i, shell)
245 const body = segment.slice(i + 1, segment[stop - 1] === char ? stop - 1 : stop)
246 if (char === '"' && /[$`]/.test(body)) isStatic = false
247 raw += segment.slice(i, stop)
248 value += body
249 i = stop
250 } else {
251 if (char === '$' || char === '`') isStatic = false
252 raw += char
253 value += char
254 i += 1
255 }
256 }
257
258 flush()
259 return words
260}
261types/index.d.ts 53 lines1export type Kind =
2 | 'git checkout'
3 | 'git restore'
4 | 'git reset --hard'
5 | 'git clean'
6 | 'git stash drop'
7 | 'git stash clear'
8 | 'git switch --discard-changes'
9 | 'recursive delete'
10 | 'before restore'
11
12/** A snapshot that is waiting to learn whether the command it guarded destroyed anything. */
13export type Pending = {
14 kind: Kind
15 root: string
16 gitDir: string
17 /** The snapshot commit, for a working-tree snapshot. */
18 sha?: string
19 /** The stash commits a `git stash drop` or `clear` is about to drop, newest first. */
20 stashes?: string[]
21 /** The changed paths at the time, to see which ones the command made disappear. */
22 before: string[]
23 skipped: number
24 at: number
25}
26
27/** A snapshot of work a command did destroy. Kept in `$.store` under `sp:<session>:<n>`. */
28export type Saved = {
29 key: string
30 kind: Kind
31 root: string
32 gitDir: string
33 sha?: string
34 stashes?: string[]
35 /** How many files the command made disappear. */
36 fileCount: number
37 /** Secrets and very large untracked files left out of the snapshot. */
38 skipped: number
39 at: number
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'save-point': {
45 pending: Record<string, Pending>
46 /** This session's saved snapshots, newest first. */
47 saved: Saved[]
48 /** How many snapshots this session has stored, for the key. */
49 count: number
50 }
51 }
52}
53