SLOPSHOPPER

checkpoint

One-click safety snapshots of your working tree (untracked files too) that never touch your branch, index or stash. /checkpoint, list, diff, restore, drop…

newcommandpromptprocesstimer
★ 1v0.1.0MITupdated 2026-10-12samuelbfernandes/moddeck/mods/checkpoint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checkpoint
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /checkpoint ⎿ checkpoint: Checkpoint · this folder is not a git repository, nothing done ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

checkpoint

Install (Claude Code 2.1.295 or later): /plugin install checkpoint --marketplace samuelbfernandes/moddeck

What it does

One-click safety snapshots of your working tree, untracked files included, that never touch your branch, your index or your stash list. A snapshot is a git commit kept under refs/moddeck/checkpoints/<name>, built with git plumbing in a temporary index (add -A, write-tree, commit-tree, update-ref). It never runs stash, reset --hard, clean or a checkout of your branch. No model is called.

Use

  • /checkpoint [name] saves a snapshot. The default name is cp-YYYYmmdd-HHMMSS. A name is lowercase letters, digits and hyphens (up to 60); list, diff, restore, drop and names starting auto- are reserved.
  • /checkpoint list: name, age and how many files differ from the working tree now. Newest first, at most 20.
  • /checkpoint diff <name>: git diff --stat between the snapshot and the working tree.
  • /checkpoint restore <name>: asks first. The dialog lists the files that differ from the snapshot and would be lost (counts, and the first 8 names). Options: Snapshot first, then restore (the first, recommended; makes before-restore-<time> so you can undo), Restore, Cancel. Esc, a dismissed dialog or any other answer is a cancel and changes nothing.
  • /checkpoint drop <name>: asks, then deletes the snapshot ref only.
  • The / is required: a bare checkpoint goes to the model like any prompt.

What restore touches

Only files named in the dialog. Files that differ are written back with git restore --source=<snapshot> --worktree for exactly those paths; files that are untracked now and not in the snapshot are removed (rm, one by one); ignored files are never looked at. Your index, branch and stash stay as they are, so a file you had staged still shows as staged. Empty folders are left behind.

Automatic snapshots

Setting autoBeforePrompt (off by default): at the first prompt of a turn that follows file changes, a snapshot auto-<time> is made from a timer after the prompt has gone in, so it never blocks or delays you. Nothing is made when the tree equals HEAD or the newest snapshot. Only the newest maxAuto (10) automatic snapshots are kept; your own named snapshots and before-restore-* copies are never deleted automatically.

Settings

  • enabled (default on).
  • autoBeforePrompt (default off).
  • maxAuto (default 10).

moddeck

The number of saved snapshots in the repository is mirrored into the atom { plugin: 'checkpoint', key: 'count' } (a number), set at session start and after every change.

Limits

  • Snapshots live in the repository's own object store (.git). Deleting the repository deletes them. They are not pushed anywhere unless you push refs/moddeck/* yourself.
  • Ignored files (.gitignore) are not in a snapshot. Very large untracked files are hashed into .git.
  • Not a git repository: it says so and does nothing. Any error becomes one plain message; nothing is blocked.
  • Windows removal of untracked files uses cmd /c del; that path is untested.

Tests

claude plugin test . runs the stubbed tests. node tests/run-integration.mjs (Node 22.15 or later, or bun tests/real-git.integration.ts) runs the same code against real temporary git repositories; it is kept out of the default test glob because the test sandbox has no process access.

Source 4 files
hooks/register.tsx 109 lines
1// checkpoint: /checkpoint saves a safety snapshot of the working tree without touching the branch, index or
2// stash; see checkpoint.ts. FAIL-OPEN: every error here ends in one plain message or silence, never a block.
3import { atom, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import { autoSnapshot, count, runCommand } from './checkpoint'
7import type { Deps } from './checkpoint'
8
9const COMMAND = 'checkpoint'
10const AUTO_DELAY_MS = 300 // the prompt is in and its turn started before the snapshot is made
11const OFF = 'Checkpoint · off · the enabled setting is false (change it in /config)'
12
13const countAtom = atom({ plugin: 'checkpoint', key: 'count' } as const, 0)
14
15type Cfg = { isEnabled: boolean; isAuto: boolean; maxAuto: number }
16
17const maxAutoOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? Math.min(100, Math.max(1, Math.round(v))) : 10)
18
19async function depsOf($: EngineInterface): Promise<Deps> {
20  let cwd: string | undefined
21  try {
22    cwd = await $.session.cwd()
23  } catch {
24    cwd = undefined
25  }
26  let t = 0
27  try {
28    t = await $.clock.now()
29  } catch {
30    t = 0
31  }
32  return {
33    run: (argv, o) => $.process.run(argv, o),
34    cwd,
35    now: () => t,
36    ask: (text, options, header) => $.ui.ask(text, { options, header }),
37    canAsk: async () => {
38      try {
39        return (await $.session.surfaces()).length > 0
40      } catch {
41        return true
42      }
43    },
44  }
45}
46
47async function mirror($: EngineInterface, n: number | undefined): Promise<void> {
48  if (n === undefined) return
49  try {
50    await update($, countAtom, () => n)
51  } catch {
52    // the side bar then shows an older number
53  }
54}
55
56export const register: Register = (on, options) => {
57  const cfg: Cfg = { isEnabled: options.enabled !== false, isAuto: options.autoBeforePrompt === true, maxAuto: maxAutoOf(options.maxAuto) }
58  let isBusy = false
59
60  on('session.start', async ($, e, next) => {
61    try {
62      await $.command.register({
63        name: COMMAND,
64        description: 'Safety snapshots of your working tree: /checkpoint [name] | list | diff <name> | restore <name> | drop <name>',
65        argumentHint: '[name | list | diff <name> | restore <name> | drop <name>]',
66      })
67    } catch {
68      // fail-open: without the command the mod is simply quiet
69    }
70    if (cfg.isEnabled) {
71      try {
72        await mirror($, await count(await depsOf($)))
73      } catch {
74        // see above
75      }
76    }
77    return next(e)
78  }).catch(($, e, next) => next(e))
79
80  on('prompt.submit', async ($, e, next) => {
81    // Only the person's own words, and only the first prompt of a turn (not one typed over a running turn).
82    const own = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
83    if (cfg.isEnabled && cfg.isAuto && own && e.turnId === undefined && !isBusy) {
84      // Nothing is awaited here: the snapshot is made from a timer after the prompt has gone in.
85      $.clock.after(AUTO_DELAY_MS, () => {
86        if (isBusy) return
87        isBusy = true
88        void (async () => {
89          try {
90            await mirror($, await autoSnapshot(await depsOf($), cfg.maxAuto))
91          } catch {
92            // never throws into the session
93          } finally {
94            isBusy = false
95          }
96        })()
97      })
98    }
99    return next(e)
100  }).catch(($, e, next) => next(e))
101
102  on('command.run', { command: COMMAND }, async ($, e) => {
103    if (!cfg.isEnabled) return { text: OFF }
104    const out = await runCommand(await depsOf($), e.args)
105    await mirror($, out.count)
106    return { text: out.text }
107  }).catch(() => ({ text: 'Checkpoint · that did not work, nothing was changed' }))
108}
109
hooks/checkpoint.ts 335 lines
1// checkpoint: safety snapshots of the working tree (untracked files included) kept as refs under
2// refs/moddeck/checkpoints/<name>. Built with git plumbing only:
3//   temporary index (GIT_INDEX_FILE) -> add -A -> write-tree -> commit-tree -> update-ref
4// so the person's branch, real index and stash list are never touched. Restore is the one destructive command:
5// it first shows what would be lost and asks. Nothing here ever runs stash, reset, clean or checkout.
6import { gitIn } from './git'
7import type { Git, Run } from './git'
8
9export const REF_PREFIX = 'refs/moddeck/checkpoints/'
10export const MAX_LISTED = 20
11export const MAX_NAMES = 8
12const MARK = 'includes-untracked'
13const AUTO = 'auto-'
14const IDENT = {
15  GIT_AUTHOR_NAME: 'moddeck checkpoint',
16  GIT_AUTHOR_EMAIL: 'checkpoint@moddeck.invalid',
17  GIT_COMMITTER_NAME: 'moddeck checkpoint',
18  GIT_COMMITTER_EMAIL: 'checkpoint@moddeck.invalid',
19}
20const WORDS = new Set(['list', 'diff', 'restore', 'drop'])
21
22export type Deps = {
23  run: Run
24  cwd: string | undefined
25  /** Milliseconds since the epoch. */
26  now: () => number
27  /** Asks the person; rejects (Esc, no screen) read as a cancel. */
28  ask: (text: string, options: string[], header: string) => Promise<string>
29  /** Whether there is a screen to ask on. */
30  canAsk: () => Promise<boolean>
31}
32
33export type Checkpoint = { name: string; sha: string; at: number }
34
35const NOT_REPO = 'Checkpoint · this folder is not a git repository, nothing done'
36const FAILED = 'Checkpoint · that did not work, nothing was changed'
37const HEADER = 'Checkpoint'
38
39class Failure extends Error {}
40const fail = (what: string, r?: { stderr: string }): never => {
41  const why = r?.stderr.split('\n').find(l => l.trim() !== '')?.trim().slice(0, 120)
42  throw new Failure(why ? `${what} (${why})` : what)
43}
44
45export const isValidName = (name: string): boolean => /^[a-z0-9][a-z0-9-]{0,59}$/.test(name) && !name.endsWith('-') && !WORDS.has(name)
46
47const two = (n: number) => String(n).padStart(2, '0')
48/** YYYYmmdd-HHMMSS in local time. */
49export function stamp(ms: number): string {
50  const t = new Date(ms)
51  return `${t.getFullYear()}${two(t.getMonth() + 1)}${two(t.getDate())}-${two(t.getHours())}${two(t.getMinutes())}${two(t.getSeconds())}`
52}
53
54export function age(ms: number, atSec: number): string {
55  const s = Math.max(0, Math.round(ms / 1000 - atSec))
56  if (s < 60) return 'just now'
57  if (s < 3600) return `${Math.floor(s / 60)}m ago`
58  if (s < 86_400) return `${Math.floor(s / 3600)}h ago`
59  return `${Math.floor(s / 86_400)}d ago`
60}
61
62const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
63const nonEmpty = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
64
65type Repo = { g: Git; root: string; gitDir: string }
66
67async function repoOf(d: Deps): Promise<Repo | undefined> {
68  const g = gitIn(d.run, d.cwd)
69  const top = await g(['rev-parse', '--show-toplevel'])
70  if (top.exitCode !== 0 || top.stdout.trim() === '') return undefined
71  const root = top.stdout.trim()
72  const gd = await gitIn(d.run, root)(['rev-parse', '--absolute-git-dir'])
73  if (gd.exitCode !== 0 || gd.stdout.trim() === '') return undefined
74  return { g: gitIn(d.run, root), root, gitDir: gd.stdout.trim() }
75}
76
77/** Tree of the whole working tree (untracked files too, ignored ones not), built in a temporary index. */
78async function workTree(r: Repo): Promise<string> {
79  const idx = `${r.gitDir.replace(/[\\/]$/, '')}/moddeck-checkpoint.index`
80  const env = { GIT_INDEX_FILE: idx }
81  const add = await r.g(['add', '-A'], { env })
82  if (add.exitCode !== 0) fail('could not read the working tree', add)
83  const tree = await r.g(['write-tree'], { env })
84  if (tree.exitCode !== 0 || !/^[0-9a-f]{40,64}$/.test(tree.stdout.trim())) fail('could not read the working tree', tree)
85  return tree.stdout.trim()
86}
87
88export async function listRefs(g: Git): Promise<Checkpoint[]> {
89  const r = await g(['for-each-ref', '--sort=-refname', '--format=%(refname)%09%(objectname)%09%(committerdate:unix)', REF_PREFIX])
90  if (r.exitCode !== 0) return []
91  const rows: Checkpoint[] = []
92  for (const line of nonEmpty(r.stdout)) {
93    const [ref = '', sha = '', at = ''] = line.split('\t')
94    if (ref.startsWith(REF_PREFIX) && sha) rows.push({ name: ref.slice(REF_PREFIX.length), sha, at: Number(at) || 0 })
95  }
96  return rows.sort((a, b) => b.at - a.at)
97}
98
99export const countOf = async (d: Deps): Promise<number | undefined> => {
100  const repo = await repoOf(d)
101  return repo ? (await listRefs(repo.g)).length : undefined
102}
103
104/** A commit holding `tree`, parented on HEAD when there is one, saved under `name`. */
105async function save(r: Repo, tree: string, name: string): Promise<void> {
106  const head = await r.g(['rev-parse', '-q', '--verify', 'HEAD'])
107  const parent = head.exitCode === 0 && head.stdout.trim() !== '' ? ['-p', head.stdout.trim()] : []
108  const c = await r.g(['commit-tree', tree, ...parent, '-m', `moddeck checkpoint ${name}\n\n${MARK}`], { env: IDENT })
109  const sha = c.stdout.trim()
110  if (c.exitCode !== 0 || !/^[0-9a-f]{40,64}$/.test(sha)) fail('could not save the snapshot', c)
111  const u = await r.g(['update-ref', `${REF_PREFIX}${name}`, sha])
112  if (u.exitCode !== 0) fail('could not save the snapshot', u)
113}
114
115const uniqueName = async (r: Repo, base: string): Promise<string> => {
116  const have = new Set((await listRefs(r.g)).map(c => c.name))
117  let name = base
118  for (let n = 2; have.has(name); n++) name = `${base}-${n}`
119  return name
120}
121
122async function treeFiles(r: Repo, tree: string): Promise<number> {
123  const t = await r.g(['ls-tree', '-r', '--name-only', '-z', tree])
124  return t.exitCode === 0 ? t.stdout.split('\0').filter(Boolean).length : 0
125}
126
127// ---------------------------------------------------------------- commands
128
129async function create(r: Repo, d: Deps, wanted: string | undefined): Promise<string> {
130  let name = wanted
131  if (name === undefined) name = await uniqueName(r, `cp-${stamp(d.now())}`)
132  else if ((await listRefs(r.g)).some(c => c.name === name)) return `Checkpoint · "${name}" already exists · pick another name or /checkpoint drop ${name}`
133  const tree = await workTree(r)
134  await save(r, tree, name)
135  return `Checkpoint · saved ${name} · ${plural(await treeFiles(r, tree), 'file')} · /checkpoint restore ${name} to go back`
136}
137
138async function list(r: Repo, d: Deps): Promise<string> {
139  const all = await listRefs(r.g)
140  if (all.length === 0) return 'Checkpoint · none saved · /checkpoint saves one'
141  let now: string | undefined
142  try {
143    now = await workTree(r)
144  } catch {
145    now = undefined // counts are then left out
146  }
147  const rows: string[] = []
148  for (const c of all.slice(0, MAX_LISTED)) {
149    let changed = ''
150    if (now !== undefined) {
151      const diff = await r.g(['diff-tree', '-r', '--name-only', '-z', '--no-renames', c.sha, now])
152      changed = diff.exitCode === 0 ? ` · ${plural(diff.stdout.split('\0').filter(Boolean).length, 'file')} changed` : ''
153    }
154    rows.push(`${c.name} · ${age(d.now(), c.at)}${changed}`)
155  }
156  const more = all.length > MAX_LISTED ? [`+${all.length - MAX_LISTED} older`] : []
157  return [`Checkpoint · ${all.length} saved`, ...rows, ...more].join('\n')
158}
159
160async function find(r: Repo, name: string): Promise<string | undefined> {
161  const v = await r.g(['rev-parse', '-q', '--verify', `${REF_PREFIX}${name}^{commit}`])
162  return v.exitCode === 0 && v.stdout.trim() !== '' ? v.stdout.trim() : undefined
163}
164
165async function showDiff(r: Repo, name: string): Promise<string> {
166  const sha = await find(r, name)
167  if (!sha) return `Checkpoint · no checkpoint named ${name}`
168  const now = await workTree(r)
169  const diff = await r.g(['diff', '--stat', '--no-renames', sha, now])
170  if (diff.exitCode !== 0) fail('could not compare', diff)
171  const rows = nonEmpty(diff.stdout)
172  if (rows.length === 0) return `Checkpoint · ${name} matches the working tree now`
173  const shown = rows.length > 30 ? [...rows.slice(0, 29), `… ${rows.length - 29} more lines`, rows[rows.length - 1]!] : rows
174  return [`Checkpoint · ${name} to now`, ...shown].join('\n')
175}
176
177type Change = { status: string; path: string }
178const parseChanges = (out: string): Change[] => {
179  const parts = out.split('\0')
180  const rows: Change[] = []
181  for (let i = 0; i + 1 < parts.length; i += 2) if (parts[i]) rows.push({ status: parts[i]!.charAt(0), path: parts[i + 1]! })
182  return rows
183}
184
185const safeRel = (p: string) => p !== '' && !/^([\\/]|[A-Za-z]:)/.test(p) && !p.split(/[\\/]/).includes('..')
186const looksWindows = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || root.includes('\\')
187
188async function removeFile(d: Deps, r: Repo, rel: string): Promise<boolean> {
189  if (!safeRel(rel)) return false
190  const argv = looksWindows(r.root) ? ['cmd', '/c', 'del', '/f', '/q', rel.replace(/\//g, '\\')] : ['rm', '-f', '--', rel]
191  try {
192    return (await d.run(argv, { cwd: r.root, timeoutMs: 20_000 })).exitCode === 0
193  } catch {
194    return false
195  }
196}
197
198async function restore(r: Repo, d: Deps, name: string): Promise<string> {
199  const sha = await find(r, name)
200  if (!sha) return `Checkpoint · no checkpoint named ${name}`
201  const now = await workTree(r)
202  const diff = await r.g(['diff-tree', '-r', '-z', '--name-status', '--no-renames', sha, now])
203  if (diff.exitCode !== 0) fail('could not compare', diff)
204  const changes = parseChanges(diff.stdout)
205  if (changes.length === 0) return `Checkpoint · ${name} already matches the working tree, nothing to restore`
206
207  const tracked = new Set((await r.g(['ls-files', '-z'])).stdout.split('\0').filter(Boolean))
208  const msg = await r.g(['log', '-1', '--format=%B', sha])
209  const hasUntracked = msg.stdout.includes(MARK)
210
211  const overwrite = changes.filter(c => c.status !== 'A' && c.status !== 'D').map(c => c.path)
212  const bringBack = changes.filter(c => c.status === 'D').map(c => c.path)
213  const added = changes.filter(c => c.status === 'A')
214  const dropTracked = added.filter(c => tracked.has(c.path)).map(c => c.path)
215  const dropUntracked = hasUntracked ? added.filter(c => !tracked.has(c.path)).map(c => c.path) : []
216  const lost = [...overwrite, ...dropTracked, ...dropUntracked]
217  const restoreList = [...overwrite, ...bringBack, ...dropTracked]
218
219  const at = (await listRefs(r.g)).find(c => c.name === name)
220  const shown = lost.slice(0, MAX_NAMES).map(p => `  ${p.replace(/[\r\n]/g, ' ')}`)
221  const text = [
222    `Restore ${name}${at ? ` (${age(d.now(), at.at)})` : ''}?`,
223    lost.length === 0
224      ? 'Nothing would be overwritten or deleted.'
225      : `${plural(lost.length, 'file')} differ from it and would be lost: ${overwrite.length} overwritten, ${dropTracked.length + dropUntracked.length} deleted.`,
226    ...shown,
227    ...(lost.length > MAX_NAMES ? [`  +${lost.length - MAX_NAMES} more`] : []),
228    ...(bringBack.length ? [`${plural(bringBack.length, 'missing file')} would come back.`] : []),
229    'Your branch, index and stash are not touched.',
230  ].join('\n')
231
232  if (!(await d.canAsk())) return 'Checkpoint · no screen to ask on, nothing changed'
233  const FIRST = 'Snapshot first, then restore'
234  let answer = 'Cancel'
235  try {
236    answer = await d.ask(text, [FIRST, 'Restore', 'Cancel'], HEADER)
237  } catch {
238    answer = 'Cancel' // Esc or no one to ask
239  }
240  if (answer !== FIRST && answer !== 'Restore') return 'Checkpoint · cancelled, nothing changed'
241
242  let before = ''
243  if (answer === FIRST) {
244    before = await uniqueName(r, `before-restore-${stamp(d.now())}`)
245    await save(r, now, before) // a failure here stops everything: nothing is restored without its safety copy
246  }
247  if (restoreList.length > 0) {
248    const res = await r.g(['restore', `--source=${sha}`, '--worktree', '--pathspec-from-file=-', '--pathspec-file-nul'], { stdin: restoreList.join('\0') + '\0' })
249    if (res.exitCode !== 0) fail('the restore did not finish, nothing more was changed', res)
250  }
251  let gone = 0
252  for (const p of dropUntracked) if (await removeFile(d, r, p)) gone++
253  const kept = before ? ` · copy saved as ${before}` : ''
254  return `Checkpoint · restored ${name} · ${overwrite.length} overwritten · ${dropTracked.length + gone} deleted · ${bringBack.length} brought back${kept}`
255}
256
257async function drop(r: Repo, d: Deps, name: string): Promise<string> {
258  const sha = await find(r, name)
259  if (!sha) return `Checkpoint · no checkpoint named ${name}`
260  if (!(await d.canAsk())) return 'Checkpoint · no screen to ask on, nothing changed'
261  let answer = 'Cancel'
262  try {
263    answer = await d.ask(`Drop checkpoint ${name}?\nIt cannot be brought back.`, ['Cancel', 'Drop'], HEADER)
264  } catch {
265    answer = 'Cancel'
266  }
267  if (answer !== 'Drop') return 'Checkpoint · cancelled, nothing changed'
268  const res = await r.g(['update-ref', '-d', `${REF_PREFIX}${name}`])
269  if (res.exitCode !== 0) fail('could not drop it', res)
270  return `Checkpoint · dropped ${name}`
271}
272
273const USAGE = 'Checkpoint · /checkpoint [name] · list · diff <name> · restore <name> · drop <name>'
274
275export type Outcome = { text: string; count?: number }
276
277/** The whole `/checkpoint` command. Never throws: any failure is one plain line. */
278export async function runCommand(d: Deps, args: string): Promise<Outcome> {
279  try {
280    const words = args.trim().split(/\s+/).filter(Boolean)
281    const sub = words[0]
282    const arg = words[1]
283    if (words.length > 2) return { text: USAGE }
284    const named = sub !== undefined && WORDS.has(sub)
285    if (named && sub !== 'list' && arg === undefined) return { text: `Checkpoint · which one? /checkpoint ${sub} <name>` }
286    if (sub === 'list' && arg !== undefined) return { text: USAGE }
287    if (!named && words.length > 1) return { text: USAGE }
288    if (!named && sub !== undefined && !isValidName(sub)) return { text: 'Checkpoint · a name uses lowercase letters, digits and hyphens only (up to 60), and not list, diff, restore or drop' }
289    if (!named && sub?.startsWith(AUTO)) return { text: 'Checkpoint · names starting auto- are kept for automatic snapshots' }
290    if (arg !== undefined && named && !/^[a-z0-9-]+$/.test(arg)) return { text: `Checkpoint · no checkpoint named ${arg.slice(0, 40)}` }
291
292    const r = await repoOf(d)
293    if (!r) return { text: NOT_REPO }
294    let text: string
295    if (sub === 'list') text = await list(r, d)
296    else if (sub === 'diff') text = await showDiff(r, arg!)
297    else if (sub === 'restore') text = await restore(r, d, arg!)
298    else if (sub === 'drop') text = await drop(r, d, arg!)
299    else text = await create(r, d, sub)
300    return { text, count: (await listRefs(r.g)).length }
301  } catch (error) {
302    return { text: error instanceof Failure ? `Checkpoint · ${error.message} · nothing was lost` : FAILED }
303  }
304}
305
306/**
307 * The automatic snapshot at a prompt: only when the working tree differs from HEAD and from the newest
308 * snapshot, then the oldest automatic ones beyond `maxAuto` are deleted. Never throws.
309 */
310export async function autoSnapshot(d: Deps, maxAuto: number): Promise<number | undefined> {
311  try {
312    const r = await repoOf(d)
313    if (!r) return undefined
314    const have = await listRefs(r.g)
315    const tree = await workTree(r)
316    const head = await r.g(['rev-parse', '-q', '--verify', 'HEAD^{tree}'])
317    const newest = have[0] ? await r.g(['rev-parse', '-q', '--verify', `${have[0].sha}^{tree}`]) : undefined
318    const same = tree === head.stdout.trim() || (newest !== undefined && tree === newest.stdout.trim())
319    if (!same) await save(r, tree, await uniqueName(r, `${AUTO}${stamp(d.now())}`))
320    const autos = (await listRefs(r.g)).filter(c => c.name.startsWith(AUTO))
321    for (const old of autos.slice(Math.max(1, maxAuto))) await r.g(['update-ref', '-d', `${REF_PREFIX}${old.name}`])
322    return (await listRefs(r.g)).length
323  } catch {
324    return undefined
325  }
326}
327
328export const count = async (d: Deps): Promise<number> => {
329  try {
330    return (await countOf(d)) ?? 0
331  } catch {
332    return 0
333  }
334}
335
hooks/git.ts 24 lines
1// Thin git layer: argv builder and a never-throwing runner. No shell is involved. Only plumbing that cannot
2// change the branch, the index or the stash list is ever issued by checkpoint.ts.
3
4export type RunResult = { exitCode: number; stdout: string; stderr: string }
5export type RunOptions = { cwd?: string; timeoutMs?: number; stdin?: string; env?: Record<string, string> }
6/** `$.process.run` as the mod needs it: an argv ARRAY, never a shell string. */
7export type Run = (argv: readonly string[], options?: RunOptions) => Promise<RunResult>
8
9const GIT_PREFIX = ['git', '-c', 'core.quotepath=off', '--no-optional-locks', '--no-pager', '--literal-pathspecs']
10const GIT_TIMEOUT_MS = 60_000
11
12export type Git = (args: readonly string[], extra?: { env?: Record<string, string>; stdin?: string }) => Promise<RunResult>
13
14/** A git runner bound to a directory. It never throws: a spawn failure or timeout reads as exit code 127. */
15export function gitIn(run: Run, cwd: string | undefined, timeoutMs: number = GIT_TIMEOUT_MS): Git {
16  return async (args, extra) => {
17    try {
18      return await run([...GIT_PREFIX, ...args], { cwd, timeoutMs, ...(extra?.env ? { env: extra.env } : {}), ...(extra?.stdin !== undefined ? { stdin: extra.stdin } : {}) })
19    } catch (error) {
20      return { exitCode: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
21    }
22  }
23}
24
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    checkpoint: {
4      /** How many checkpoints are saved in this repository (refs/moddeck/checkpoints), mirrored for the moddeck side bar. */
5      count: number
6    }
7  }
8}
9