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

Install (Claude Code 2.1.295 or later): /plugin install checkpoint --marketplace samuelbfernandes/moddeck
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.
/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./ is required: a bare checkpoint goes to the model like any prompt.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.
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.
enabled (default on).autoBeforePrompt (default off).maxAuto (default 10).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.
.git). Deleting the repository deletes them. They are not pushed anywhere unless you push refs/moddeck/* yourself..gitignore) are not in a snapshot. Very large untracked files are hashed into .git.cmd /c del; that path is untested.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.
hooks/register.tsx 109 lines1// 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}
109hooks/checkpoint.ts 335 lines1// 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}
335hooks/git.ts 24 lines1// 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}
24types/index.d.ts 9 lines1declare 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