SLOPSHOPPER

save-point

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

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-04ivanvyd/ground-rules/plugins/save-point
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · save-point
│ ┃ save-point ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing has been saved yet. │ ┃ A snapshot is a git object with no ref. git ⏺ Read(src/auth.ts) │ ┃ gc removes it after about 14 days. ⎿ 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 │ │ › /save-point │ ⎿ save-point: save-point pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · save-point
Nothing has been saved yet. A snapshot is a git object with no ref. git gc removes it after about 14 days.
README

save-point

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.

Install

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.

What it does

  1. Spots a destructive command before it runs. A classic.PreToolUse hook reads the Bash or PowerShell command and looks for:
  2. git checkout -- <paths>, git checkout ., git checkout -f
  3. git restore, unless it is --staged only
  4. git reset --hard, git clean (except a dry run)
  5. git stash drop and git stash clear
  6. git switch -f and git switch --discard-changes
  7. rm -r, Remove-Item -Recurse, rmdir /s, del /s, when a target is inside the repository

It follows cd and git -C to find the directory the command runs in, and uses the working directory Claude Code reports for the call.

  1. Takes a snapshot if the working tree has changes. The hook waits for it, up to 15 seconds, then lets the command run. A timeout or a git failure shows a toast and never blocks the command.
  2. Checks afterwards. A 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.
  3. Restores from /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.

How a snapshot is made

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>.

Measured

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.

Settings

None. GROUND_RULES_PRESENTATION=1 hides file and repository names on screen.

What it reads, runs and stores

Hookssession.start, classic.PreToolUse, classic.PostToolUse, classic.PostToolUseFailure, command.run, and ui.render for the band and the pane.
ReadsThe command text, git status, file sizes of untracked files.
Runsgit plumbing: rev-parse, status, read-tree, add, write-tree, commit-tree. On restore only: cat-file, diff --name-only, restore, stash apply.
Storessp:<session>:<n>: the snapshot commit, repository path, kind and file count. Records older than 15 days are deleted when a session starts.
NeverA denial, a rewrite of the command, a push, a ref.

Limits

  • Git removes unreferenced objects after 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.
  • Repositories with .gitattributes end-of-line rules can restore with normalised line endings.
  • Submodule contents are not snapshotted.
  • A command that Claude Code approves but another hook blocks after the snapshot leaves an unused commit object behind. It is harmless and git collects it.
  • The destructive-command list is a pattern match on the command text. A command built by a script or a variable is not recognised.
  • If git fails for any reason, the snapshot reports an error and the command runs.
Source 7 files
hooks/register.tsx 261 lines
1import { 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}
261
hooks/format.ts 82 lines
1import 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}
82
hooks/patterns.ts 187 lines
1import { 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}
187
hooks/privacy.ts 28 lines
1// 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'
28
hooks/snapshot.ts 192 lines
1// 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}
192
hooks/scan.ts 261 lines
1// 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}
261
types/index.d.ts 53 lines
1export 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