SLOPSHOPPER

mulligan

A do-over for risky shell commands: it saves your files first, and /mulligan puts them back.

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/mulligan
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mulligan
│ ┃ mulligan ✕ › fix the failing auth test and add an audit log call │ ┃ No do-overs yet for this project │ ┃ Files in this project only: a database, a ⏺ Read(src/auth.ts) │ ┃ remote or anything outside the folder is ⎿ Read 6 lines │ ┃ not covered. ⏺ 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 │ │ › /mulligan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mulligan
No do-overs yet for this project Files in this project only: a database, a remote or anything outside the folder is not covered.
README

<img src="../../brand/pixel-icons/svg/mulligan.svg" width="64" height="64" alt="">

Mulligan

A do-over for risky shell commands: it saves your files first, and /mulligan puts them back.

mulligan: saved a do-over before "rm -rf dist" (dist) · /mulligan restore 3

3 do-overs for this project
#3  rm -rf dist                                   [Restore] [Diff]
    dist · 4.2 MB · just now
#2  git reset --hard                              [Restore] [Diff]
    tracked changes · 2h ago

Install

/plugin install mulligan --marketplace modmyclaude/claude-code-mods

Use it

Nothing to set up. Claude Code's /rewind puts back the files Claude edited with its own tools; it does not undo what a shell command did. Mulligan covers that:

  • Before a destructive Bash command runs, it saves what that command can destroy:
  • rm -r, find ... -delete, xargs rm and a redirect over a file (> notes.md): the paths they name.
  • git reset --hard, git checkout or git restore over changes: your tracked files as they are, as a git commit object (git stash create, which moves no branch and leaves the stash list alone).
  • git clean: the untracked files git lists (not the ignored ones).
  • A target only known as it runs (rm -rf "$OUT", rm -rf **): the whole project, without .git and node_modules.
  • Once the command ran, a dim line says a do-over exists for a few seconds. A command that another mod held or refused leaves no snapshot.
  • /mulligan lists this project's snapshots, newest first, with what each holds and when.
  • Restore asks first: Yes, restore writes the saved files back over what is there now, and deletes nothing newer. Claude is then told the files changed under it and to re-read any file before editing it.
  • Diff shows what differs now from a snapshot: files deleted since, and files changed.

The same commands last-look holds take a snapshot, and nothing else: a listing, a test run, a single-file rm, a push or a branch delete never does.

CommandWhat it does
/mulliganThe list, in a pane
/mulligan restore <n>Snapshot n, waiting on your confirm press
/mulligan diff <n>What differs now from snapshot n
/mulligan drop <n>Forget snapshot n and empty its archive (only when you type it)

"Only when you type it" means typed by you in the terminal, the desktop app or Remote Control. A program you run that drives Claude Code through its SDK counts as you too, since the desktop app sends that way; another mod, a peer session or Claude running the command is refused.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

  • On: on by default. Off, Mulligan saves nothing before any command (no git, no tar, no file written) and draws no line; /mulligan says it is off. /mulligan restore <n> still puts back a do-over saved while it was on, after the same confirm: those are your files, and turning it on just to reach them would start saving again, which can reuse the oldest slot. A flip never deletes a do-over. /config or the modmyclaude hub flips it, live.
  • Always save before commands matching: a pattern (a regular expression, case-insensitive; | between several) for commands that should save the whole project first, such as prisma migrate|npm uninstall.
  • Never save before commands matching: a pattern for commands that never take a snapshot, such as rm -rf (dist|build).
  • Largest snapshot (MB): default 200. A bigger one is skipped, and the line says the command ran without a do-over.

What it can touch

  • Writes: archives and a small index (each snapshot's command text, time and size) under ~/.claude/mulligan/<project key>/ (or under CLAUDE_CONFIG_DIR when set): ten slots per project, reused in turn, never more. The key is a hash of the project's path. Each archive holds copies of your project's files, so anything in them (a .env among them) sits there too, in your home folder only.
  • Runs: git rev-parse, git stash create, git ls-files, git diff and, on a restore you confirmed, git restore --overlay, all with git's optional locks off, so it never takes the index lock under Claude. tar to make, list and unpack archives, and mkdir and mv inside its own folder. It never runs rm, never moves a branch, never touches .git beyond the objects git stash create adds.
  • Reads: each Bash command's text (to see whether it destroys files), the shell's current folder, your project's files when it saves them.
  • Tells Claude: after a restore, one note that the files changed and to re-read before editing. Nothing else: /mulligan answers in a pane and toasts.
  • Network, model calls: none.

Limits

  • Files in this project only. A database, a remote, a package registry or anything outside the project folder is not covered, and it says so when a command reaches outside.
  • Ignored files are not saved by git clean -x; the line says so.
  • A command that fails, or one git refused, still keeps its snapshot: a failing rm -rf can delete most of what it named before it stops.
  • A restore puts back what the snapshot holds. A file the command created is not removed, and edits made since to a restored file are written over (Diff shows them first).
  • Restoring tracked changes needs git 2.23 or newer (git restore).
  • Saving reads the files it copies before the command runs; a large tree can add up to 20 seconds to that command, and a tree over the size limit is skipped.
  • Snapshots of tracked changes are git objects no branch points to. git keeps them for two weeks by default before a cleanup may remove them.
  • The pane, the line and the restore are checked through the engine's test kit on the terminal and desktop surfaces, with git and tar stood in for; a real restore on a scratch project is part of the overnight check.

Built from a prompt

This mod was built from the Mulligan prompt.

More mods: the full catalogue.

Source 6 files
hooks/register.tsx 535 lines
1// mulligan: a do-over for shell commands. Before Claude runs a Bash command
2// that destroys files (the same classifier last-look uses: rm -r, find
3// -delete, xargs rm, git clean, git reset --hard, git checkout or restore over
4// changes, git worktree remove, a redirect over a file), it saves what that
5// command can destroy: the paths it names, or git's tracked changes as a
6// commit object (`git stash create`, which moves no ref), or the untracked
7// files, or the whole project when the targets are only known as it runs.
8// /mulligan lists them and puts one back after a confirm press.
9//
10// Saved before the tool runs, into a pending archive; kept only once the
11// command really ran (a hold beneath that refuses it leaves no snapshot). Ten
12// per project, in slots under ~/.claude/mulligan/<project key>/, reused in
13// turn: nothing is ever deleted with rm. A restore writes files back over what
14// is there and deletes nothing newer, then tells Claude to re-read.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { MulliganSnapshot } from '../types'
20import { classify } from './classify'
21import { agoWords, clean, isEmpty, oneLine, planFor, projectKey, restoreNote, scopeWords, shown, sizeWords, treePlan } from './plan'
22import type { Plan } from './plan'
23
24const SNAPSHOTS = { plugin: 'mulligan', key: 'snapshots' } as const
25const BAND = { plugin: 'mulligan', key: 'band' } as const
26const VIEW = { plugin: 'mulligan', key: 'view' } as const
27const snapshots = atom(SNAPSHOTS, [])
28const band = atom(BAND, null)
29const view = atom(VIEW, { kind: 'list', confirm: 0 })
30
31/** Snapshots kept per project, and so the archive slots. */
32export const KEEP = 10
33const BAND_MS = 8_000
34const PANE = 'mulligan'
35const TAR_MS = 20_000
36const DIFF_FILES = 50
37/** Origins that are the person: typed, the phone bridge, the desktop app's box. */
38const PERSON = new Set(['composer', 'bridge', 'sdk'])
39/** git never takes the index lock for us, never prompts, and speaks C. */
40const ENV = { LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }
41const SHA = /^[0-9a-f]{40,64}$/
42
43export type Settings = { always: RegExp | undefined; ignore: RegExp | undefined; capBytes: number }
44
45/** The person's settings: patterns that do not compile are left out, the cap clamped. */
46export function readSettings(options: Record<string, unknown>): Settings {
47  const pattern = (v: unknown) => {
48    if (typeof v !== 'string' || v.trim() === '' || v.length > 500) return undefined
49    try {
50      return new RegExp(v, 'i')
51    } catch {
52      return undefined
53    }
54  }
55  const mb = typeof options.capMB === 'number' && Number.isFinite(options.capMB) ? Math.min(5_000, Math.max(1, options.capMB)) : 200
56  return { always: pattern(options.always), ignore: pattern(options.ignore), capBytes: Math.round(mb * 1_000_000) }
57}
58
59type Index = { v: 1; next: number; snapshots: MulliganSnapshot[] }
60type Where = { root: string; cwd: string; home: string | undefined; base: string }
61type Pending = { command: string; plan: Plan; sha?: string; archive?: string; bytes: number; where: Where }
62type Prepared = { pending?: Pending; skip?: { text: string; tone: 'subtle' | 'warning' } }
63
64let settings: Settings = readSettings({})
65
66// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Mulligan saves
67// nothing before any command and draws no band. The do-overs it already saved stay, and
68// /mulligan restore still puts one back: they are the person's files, and turning the
69// switch on just to reach them would start saving again, which can reuse the oldest slot.
70// The engine reloads the mod with the new value; the config.set hook below is the same
71// switch at once.
72let isOn = true
73const OFF_TEXT = 'Mulligan is off. /mods or /config turns it on.'
74
75/** Index writes one at a time: Claude can run two commands at once. */
76let chain: Promise<unknown> = Promise.resolve()
77
78/** Runs `work` after every index write before it; a failure never blocks the next one. */
79function queued<T>(work: () => Promise<T>): Promise<T> {
80  const done = chain.then(work)
81  chain = done.catch(() => undefined)
82  return done
83}
84let pendingCount = 0
85
86function debug($: EngineInterface, error: unknown): void {
87  $.ui.log(`mulligan: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
88}
89
90function run($: EngineInterface, argv: string[], cwd: string, stdin?: string, timeoutMs = 15_000) {
91  return $.process.run(argv, { cwd, env: ENV, timeoutMs, ...(stdin === undefined ? {} : { stdin }) })
92}
93
94async function whereNow($: EngineInterface): Promise<Where | null> {
95  const [root, cwd, config, home] = await Promise.all([$.session.root(), $.session.cwd(), $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME')])
96  const dir = config ? config.replace(/\/+$/, '') : home ? `${home.replace(/\/+$/, '')}/.claude` : undefined
97  if (dir === undefined) return null
98  return { root, cwd, home: home || undefined, base: `${dir}/mulligan/${projectKey(root)}` }
99}
100
101async function isGitAt($: EngineInterface, root: string): Promise<boolean> {
102  try {
103    const r = await run($, ['git', 'rev-parse', '--is-inside-work-tree'], root)
104    return r.exitCode === 0 && r.stdout.trim() === 'true'
105  } catch {
106    return false
107  }
108}
109
110/** The project's snapshot list, from its folder; empty when there is none or it does not read. */
111async function readIndex($: EngineInterface, base: string): Promise<Index> {
112  try {
113    const parsed = JSON.parse(await $.fs.read(`${base}/index.json`)) as Partial<Index>
114    if (parsed.v !== 1 || typeof parsed.next !== 'number' || !Array.isArray(parsed.snapshots)) throw new Error('not an index')
115    return { v: 1, next: parsed.next, snapshots: parsed.snapshots.filter(s => typeof s?.n === 'number').slice(-KEEP) }
116  } catch {
117    return { v: 1, next: 1, snapshots: [] }
118  }
119}
120
121async function sizeOf($: EngineInterface, base: string, name: string): Promise<number> {
122  try {
123    return (await $.fs.list(base)).find(entry => entry.name === name)?.size ?? 0
124  } catch {
125    return 0
126  }
127}
128
129/** Empties one of our own archives: the API has no delete, and an empty file costs nothing. */
130async function emptyArchive($: EngineInterface, base: string, name: string): Promise<void> {
131  if (!/^(?:slot-\d|pending-\d+)\.tar$/.test(name)) return
132  await $.fs.write(`${base}/${name}`, '').catch(() => undefined)
133}
134
135/**
136 * Before the command runs: what it can destroy, saved into a pending archive
137 * (and a git object). A skip says why nothing was saved, when that is news.
138 */
139async function prepare($: EngineInterface, shellCommand: string): Promise<Prepared> {
140  if (settings.ignore?.test(shellCommand)) return {}
141  const where = await whereNow($)
142  if (where === null) return {}
143  const isGit = await isGitAt($, where.root)
144  let plan = planFor(classify(shellCommand, where.home === undefined ? {} : { home: where.home }), where.cwd, where.root, isGit)
145  if (settings.always?.test(shellCommand)) plan = { ...treePlan(isGit), outside: plan?.outside ?? [], notes: plan?.notes ?? [] }
146  if (plan === null) return {}
147  const cmd = oneLine(clean(shellCommand), 48)
148  if (isEmpty(plan.scope)) {
149    return plan.outside.length > 0 ? { skip: { text: `mulligan: no do-over for "${cmd}": ${clean(plan.outside[0]!)} is outside the project.`, tone: 'subtle' } } : {}
150  }
151  const { root, base } = where
152  let sha: string | undefined
153  if (plan.scope.isTracked) {
154    const made = await run($, ['git', 'stash', 'create'], root).catch(() => undefined)
155    sha = made?.exitCode === 0 ? made.stdout.trim() : undefined
156    if (!sha) {
157      // Nothing changed since the last commit: HEAD is the tracked files' state.
158      const head = await run($, ['git', 'rev-parse', '--verify', 'HEAD'], root).catch(() => undefined)
159      sha = head?.exitCode === 0 ? head.stdout.trim() : undefined
160    }
161    if (sha !== undefined && !SHA.test(sha)) sha = undefined
162  }
163
164  // The files to copy: the whole tree without git; else the untracked ones and the named paths that exist.
165  const isWholeTree = plan.scope.isTree && !plan.scope.isTracked
166  const files: string[] = []
167  if (!isWholeTree) {
168    if (plan.scope.isUntracked) {
169      const listed = await run($, ['git', 'ls-files', '--others', '--exclude-standard', '-z', '--', '.'], root).catch(() => undefined)
170      if (listed?.exitCode === 0) files.push(...listed.stdout.split('\0').filter(name => name !== ''))
171    }
172    for (const path of plan.scope.paths) {
173      if (await $.fs.exists(path).catch(() => false)) files.push(shown(path, root))
174    }
175  }
176  let archive: string | undefined
177  let bytes = 0
178  if (isWholeTree || files.length > 0) {
179    const name = `pending-${++pendingCount}.tar`
180    await run($, ['mkdir', '-p', base], root)
181    const argv = isWholeTree
182      ? ['tar', '-cf', `${base}/${name}`, '--exclude=.git', '--exclude=node_modules', '.']
183      : ['tar', '-cf', `${base}/${name}`, '--exclude=.git', '--null', '-T', '-']
184    let ok = false
185    try {
186      const made = await run($, argv, root, isWholeTree ? undefined : `${files.join('\0')}\0`, TAR_MS)
187      ok = made.exitCode === 0
188    } catch {
189      ok = false // too slow: too big
190    }
191    bytes = ok ? await sizeOf($, base, name) : 0
192    if (!ok || bytes > settings.capBytes) {
193      await emptyArchive($, base, name)
194      const why = ok ? `too big to save (${sizeWords(bytes)})` : 'could not be saved'
195      if (sha === undefined) return { skip: { text: `mulligan: ${why}; "${cmd}" runs without a do-over.`, tone: 'warning' } }
196      plan = { ...plan, notes: [...plan.notes, `the files it names were ${why}`] }
197      bytes = 0
198    } else {
199      archive = name
200    }
201  }
202  if (sha === undefined && archive === undefined) return {} // nothing there to lose (a redirect to a new file)
203  return { pending: { command: shellCommand, plan, ...(sha === undefined ? {} : { sha }), ...(archive === undefined ? {} : { archive }), bytes, where } }
204}
205
206/** After the command ran: the pending snapshot takes its slot, the oldest gives way. */
207async function commit($: EngineInterface, p: Pending): Promise<MulliganSnapshot> {
208  const { base, root } = p.where
209  const index = await readIndex($, base)
210  const n = index.next
211  const slot = `slot-${(n - 1) % KEEP}.tar`
212  if (p.archive !== undefined) {
213    const moved = await run($, ['mv', '-f', `${base}/${p.archive}`, `${base}/${slot}`], root)
214    if (moved.exitCode !== 0) throw new Error(`could not keep the snapshot: ${moved.stderr.trim()}`)
215  }
216  const snap: MulliganSnapshot = {
217    n,
218    at: await $.clock.now(),
219    command: oneLine(clean(p.command), 160),
220    scope: scopeWords(p.plan.scope, root),
221    ...(p.sha === undefined ? {} : { sha: p.sha }),
222    ...(p.archive === undefined ? {} : { archive: slot }),
223    bytes: p.bytes,
224  }
225  const kept = [...index.snapshots, snap].slice(-KEEP)
226  for (const old of index.snapshots) {
227    if (!kept.includes(old) && old.archive !== undefined && old.archive !== snap.archive) await emptyArchive($, base, old.archive)
228  }
229  await $.fs.write(`${base}/index.json`, JSON.stringify({ v: 1, next: n + 1, snapshots: kept } satisfies Index))
230  await update($, snapshots, () => kept)
231  return snap
232}
233
234async function showBand($: EngineInterface, text: string, tone: 'subtle' | 'warning'): Promise<void> {
235  const until = (await $.clock.now()) + BAND_MS
236  await update($, band, () => ({ text, tone, until }))
237  $.clock.after(BAND_MS, () => {
238    void update($, band, b => (b !== null && b.until <= until ? null : b)).catch(() => undefined)
239  })
240}
241
242async function findSnapshot($: EngineInterface, n: number): Promise<{ where: Where; snap: MulliganSnapshot } | null> {
243  const where = await whereNow($)
244  if (where === null) return null
245  const snap = (await readIndex($, where.base)).snapshots.find(s => s.n === n)
246  return snap === undefined ? null : { where, snap }
247}
248
249/** Puts snapshot n back: files written over what is there, nothing newer deleted; Claude is told. */
250async function restore($: EngineInterface, n: number): Promise<string> {
251  const found = await findSnapshot($, n)
252  if (found === null) return `mulligan: there is no snapshot ${n} for this project.`
253  const { where, snap } = found
254  const problems: string[] = []
255  if (snap.sha !== undefined && SHA.test(snap.sha)) {
256    const r = await run($, ['git', 'restore', '--overlay', `--source=${snap.sha}`, '--worktree', '--', '.'], where.root).catch(() => undefined)
257    if (r?.exitCode !== 0) problems.push(`tracked files (${oneLine(r?.stderr ?? 'git did not answer', 80)})`)
258  }
259  if (snap.archive !== undefined) {
260    if ((await sizeOf($, where.base, snap.archive)) === 0) problems.push('its files (the archive is gone)')
261    else {
262      const r = await run($, ['tar', '-xf', `${where.base}/${snap.archive}`], where.root, undefined, 60_000).catch(() => undefined)
263      if (r?.exitCode !== 0) problems.push(`files (${oneLine(r?.stderr ?? 'tar did not finish', 80)})`)
264    }
265  }
266  try {
267    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: restoreNote(snap.command, n) }] } })
268  } catch (error) {
269    debug($, error)
270  }
271  return problems.length === 0 ? `mulligan: restored #${n} (${snap.scope}). Claude was told to re-read files.` : `mulligan: #${n} restored in part; not put back: ${problems.join('; ')}.`
272}
273
274/** What differs now from snapshot n: tracked files through git, copied files one by one. */
275async function diffLines($: EngineInterface, n: number): Promise<string[]> {
276  const found = await findSnapshot($, n)
277  if (found === null) return [`There is no snapshot ${n} for this project.`]
278  const { where, snap } = found
279  const lines: string[] = []
280  if (snap.sha !== undefined && SHA.test(snap.sha)) {
281    const r = await run($, ['git', 'diff', '--name-status', snap.sha, '--', '.'], where.root).catch(() => undefined)
282    const words: Record<string, string> = { M: 'changed', D: 'deleted', A: 'new since', T: 'changed' }
283    for (const line of (r?.exitCode === 0 ? r.stdout : '').split('\n').filter(Boolean).slice(0, 100)) {
284      const [status = '', path = ''] = line.split('\t')
285      lines.push(`${words[status[0] ?? ''] ?? 'differs'}: ${path}`)
286    }
287  }
288  if (snap.archive !== undefined && (await sizeOf($, where.base, snap.archive)) > 0) {
289    const file = `${where.base}/${snap.archive}`
290    const listed = await run($, ['tar', '-tf', file], where.root).catch(() => undefined)
291    const entries = (listed?.exitCode === 0 ? listed.stdout : '').split('\n').filter(name => name !== '' && !name.endsWith('/'))
292    for (const entry of entries.slice(0, DIFF_FILES)) {
293      const path = `${where.root}/${entry.replace(/^\.\//, '')}`
294      if (!(await $.fs.exists(path).catch(() => false))) {
295        lines.push(`deleted: ${entry.replace(/^\.\//, '')}`)
296        continue
297      }
298      const then = await run($, ['tar', '-xOf', file, '--', entry], where.root).catch(() => undefined)
299      const now = await $.fs.read(path).catch(() => undefined)
300      if (then?.exitCode === 0 && now !== undefined && then.stdout !== now) lines.push(`changed: ${entry.replace(/^\.\//, '')}`)
301    }
302    if (entries.length > DIFF_FILES) lines.push(`and ${entries.length - DIFF_FILES} more files not compared`)
303  }
304  return lines.length > 0 ? lines : ['Nothing differs: the files it holds are as they were.']
305}
306
307async function openPane($: EngineInterface): Promise<boolean> {
308  try {
309    return (await $.ui.open({ id: PANE, title: 'mulligan', focus: true, closeOnEscape: true })).isPlaced
310  } catch {
311    return false
312  }
313}
314
315/** The list as the project's folder has it (another session in this project may have added one). */
316async function syncList($: EngineInterface): Promise<void> {
317  const where = await whereNow($)
318  if (where === null) return
319  const list = (await readIndex($, where.base)).snapshots
320  await update($, snapshots, () => list)
321}
322
323/** /mulligan: answers in the pane and toasts, never text Claude reads. */
324async function command($: EngineInterface, args: string, origin: string): Promise<void> {
325  const [verb = '', arg = ''] = args.trim().split(/\s+/)
326  const action = verb.toLowerCase()
327  const n = Number(arg)
328  // Off, a restore is the one word that still runs.
329  if (!isOn && action !== 'restore') return $.ui.toast(OFF_TEXT)
330  await syncList($)
331  if (action === '' || action === 'list') {
332    await update($, view, () => ({ kind: 'list' as const, confirm: 0 }))
333    if (!(await openPane($))) $.ui.toast(`mulligan: ${(await read($, snapshots)).length} do-overs for this project; the list draws in a pane, which only the terminal and the desktop app have.`)
334    return
335  }
336  if (!Number.isInteger(n) || n < 1 || !(await read($, snapshots)).some(s => s.n === n)) {
337    return $.ui.toast(`mulligan: there is no snapshot ${oneLine(clean(arg), 12) || '(none given)'} for this project.${isOn ? ' /mulligan lists them.' : ''}`)
338  }
339  if (action === 'restore') {
340    await update($, view, () => ({ kind: 'list' as const, confirm: n }))
341    if (!(await openPane($))) $.ui.toast('mulligan: a restore waits on a confirm in a pane, which only the terminal and the desktop app have.')
342    return
343  }
344  if (action === 'diff') {
345    const lines = await diffLines($, n)
346    await update($, view, () => ({ kind: 'diff' as const, n, lines }))
347    if (!(await openPane($))) $.ui.toast(`mulligan: #${n} differs in ${lines.length} place${lines.length === 1 ? '' : 's'}; the list draws in a pane.`)
348    return
349  }
350  if (action === 'drop') {
351    if (!PERSON.has(origin)) return $.ui.toast('mulligan: /mulligan drop runs only when you type it yourself.')
352    const where = await whereNow($)
353    if (where === null) return
354    await queued(async () => {
355      const index = await readIndex($, where.base)
356      const gone = index.snapshots.find(s => s.n === n)
357      if (gone?.archive !== undefined) await emptyArchive($, where.base, gone.archive)
358      const kept = index.snapshots.filter(s => s.n !== n)
359      await $.fs.write(`${where.base}/index.json`, JSON.stringify({ v: 1, next: index.next, snapshots: kept } satisfies Index))
360      await update($, snapshots, () => kept)
361    })
362    return $.ui.toast(`mulligan: dropped #${n}.`)
363  }
364  $.ui.toast(`mulligan: unknown command "${oneLine(clean(verb), 24)}". /mulligan lists; restore <n>, diff <n>, drop <n>.`)
365}
366
367export const register: Register = (on, options) => {
368  settings = readSettings(options)
369  isOn = options.enabled !== false
370
371  // Never touches a snapshot: the switch only stops new ones and the band.
372  on('config.set', { key: 'mulligan.enabled' }, async ($, e, next) => {
373    const set = await next(e)
374    if (set.deny === undefined && typeof set.value === 'boolean') {
375      isOn = set.value
376      $.ui.invalidate('ui.render')
377    }
378    return set
379  })
380
381  on('session.start', async ($, e, next) => {
382    try {
383      await $.command.register({
384        name: 'mulligan',
385        description: 'List the do-overs saved before destructive commands, and put one back',
386        argumentHint: '[restore <n> | diff <n> | drop <n>]',
387        immediate: true,
388      })
389    } catch (error) {
390      debug($, error)
391    }
392    return next(e)
393  })
394
395  // Saved before the tool runs; kept once it ran. The tool runs exactly once whatever happens here.
396  // Off, no snapshot: the call passes as it came. One taken before a switch mid-command is still
397  // kept (its files are already saved); its band is not drawn while off.
398  on('tool.call', async ($, e, next) => {
399    if (!isOn || e.tool !== 'Bash') return next(e)
400    let prepared: Prepared = {}
401    try {
402      prepared = await prepare($, e.command)
403    } catch (error) {
404      debug($, error)
405    }
406    const ran = await next(e)
407    try {
408      const { pending, skip } = prepared
409      if (ran.deny !== undefined) {
410        // Held or refused beneath: the command never ran, so there is nothing to undo.
411        if (pending?.archive !== undefined) await emptyArchive($, pending.where.base, pending.archive)
412      } else if (pending !== undefined) {
413        const snap = await queued(() => commit($, pending))
414        const cmd = oneLine(clean(e.command), 40)
415        const extra = [...pending.plan.notes, ...pending.plan.outside.map(path => `${clean(path)} is outside the project, not saved`)]
416        await showBand($, `mulligan: saved a do-over before "${cmd}" (${snap.scope}) · /mulligan restore ${snap.n}${extra.length ? ` · ${extra.join('; ')}` : ''}`, extra.length ? 'warning' : 'subtle')
417      } else if (skip !== undefined) {
418        await showBand($, skip.text, skip.tone)
419      }
420    } catch (error) {
421      debug($, error)
422    }
423    return ran
424  })
425
426  // One line under the other bands, for a few seconds after a snapshot or a skip.
427  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
428    if (!isOn) return next(e)
429    const below = await next(e)
430    const b = await read($, band)
431    if (b === null || (await $.clock.now()) >= b.until || (e.surface !== 'terminal' && e.surface !== 'desktop')) return below
432    const { Box, Text } = $.ui.resolve(e)
433    return (
434      <Box flexDirection="column">
435        {below}
436        <Text key="mulligan" dimColor={b.tone === 'subtle'} color={b.tone === 'warning' ? 'warning' : undefined}>
437          {b.text}
438        </Text>
439      </Box>
440    )
441  }).catch(($, e, next) => next(e))
442
443  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
444    const { Box, Button, Text } = $.ui.resolve(e)
445    const list = await read($, snapshots)
446    const v = await read($, view)
447    const now = await $.clock.now()
448    // A restore waits on this press, on or off.
449    const confirm = (s: MulliganSnapshot) => (
450      <Box key="confirm" flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
451        <Text key="ask">{`Put back ${clean(s.scope)} as it was before #${s.n}? Files it holds are written over what is there now; nothing newer is deleted.`}</Text>
452        <Box key="choices" flexDirection="row" gap={2}>
453          <Button
454            key="yes"
455            label="Yes, restore"
456            onPress={async () => {
457              const said = await restore($, s.n)
458              await update($, view, () => ({ kind: 'list' as const, confirm: 0 }))
459              $.ui.toast(said)
460            }}
461          />
462          <Button key="cancel" label="Cancel" autoFocus onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: 0 }))} />
463        </Box>
464      </Box>
465    )
466    if (!isOn) {
467      const asked = v.kind === 'list' ? list.find(s => s.n === v.confirm) : undefined
468      return (
469        <Box flexDirection="column" paddingX={1}>
470          <Text key="off" dimColor>
471            {OFF_TEXT}
472          </Text>
473          {asked === undefined ? null : confirm(asked)}
474        </Box>
475      )
476    }
477    if (v.kind === 'diff') {
478      return (
479        <Box flexDirection="column" paddingX={1}>
480          <Text key="title" bold>{`#${v.n} against the files now`}</Text>
481          {v.lines.map((line, i) => (
482            <Text key={`diff-${i}`}>{clean(line)}</Text>
483          ))}
484          <Button key="back" label="Back to the list" onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: 0 }))} />
485        </Box>
486      )
487    }
488    return (
489      <Box flexDirection="column" paddingX={1}>
490        <Text key="title" bold>
491          {list.length === 0 ? 'No do-overs yet for this project' : `${list.length} do-over${list.length === 1 ? '' : 's'} for this project`}
492        </Text>
493        {[...list].reverse().map(s => (
494          <Box key={`snap-${s.n}`} flexDirection="column">
495            <Box key="line" flexDirection="row" gap={1}>
496              <Box key="n" width={4} flexShrink={0}>
497                <Text dimColor>{`#${s.n}`}</Text>
498              </Box>
499              <Box key="command" flexGrow={1} flexShrink={1} minWidth={8}>
500                <Text wrap="truncate-end">{clean(s.command)}</Text>
501              </Box>
502              <Box key="buttons" flexShrink={0} flexDirection="row" gap={1}>
503                <Button key="restore" label="Restore" onPress={() => void update($, view, () => ({ kind: 'list' as const, confirm: s.n }))} />
504                <Button
505                  key="diff"
506                  label="Diff"
507                  onPress={async () => {
508                    const lines = await diffLines($, s.n)
509                    await update($, view, () => ({ kind: 'diff' as const, n: s.n, lines }))
510                  }}
511                />
512              </Box>
513            </Box>
514            <Text key="about" dimColor>{`    ${clean(s.scope)}${s.bytes > 0 ? ` · ${sizeWords(s.bytes)}` : ''} · ${agoWords(now - s.at)}`}</Text>
515            {v.confirm === s.n ? confirm(s) : null}
516          </Box>
517        ))}
518        <Text key="note" dimColor>
519          Files in this project only: a database, a remote or anything outside the folder is not covered.
520        </Text>
521      </Box>
522    )
523  })
524
525  on('command.run', { command: 'mulligan' }, async ($, e) => {
526    try {
527      await command($, e.args, e.origin.kind)
528    } catch (error) {
529      debug($, error)
530      $.ui.toast('mulligan: that command failed, and nothing changed.')
531    }
532    return {}
533  })
534}
535
hooks/classify.ts 727 lines
1// The same file as last-look's hooks/classify.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// Pure: given a Bash command string, which parts of it can destroy work?
5//
6// No `$`, no I/O: the hook measures what this finds. The rule of thumb is to
7// catch what the spec names in every spelling a model actually writes (flag
8// order, sudo and env prefixes, quoted paths, `cd x && ...`, subshells,
9// `bash -c`, `$(...)`), and to leave look-alikes alone (`echo "rm -rf"`,
10// `grep -r`, a single-file `rm`).
11
12import { literal, parse } from './shell'
13import type { Item, Part, RawWord, Redir, Seq } from './shell'
14
15/** A word as the shell would hand it to the program, when that can be known. */
16export type Word = {
17  text: string
18  /** False when it holds an expansion nobody can know ahead (`$X`, `$(...)`). */
19  known: boolean
20  /** True when unquoted `*`, `?` or `[` make it a pattern the shell expands. */
21  glob: boolean
22  /** As written in the command. */
23  raw: string
24  /**
25   * A literal path as written: no glob, brace, `$VAR`, `~`, `$(...)` or
26   * backticks. Only such paths may be skipped by alwaysAllow, and only they
27   * can be reported "not found" with confidence.
28   */
29  plain: boolean
30}
31
32/** One `cd` on the way to where a command runs. */
33export type CdStep = {
34  dir: Word
35  /**
36   * True when the command runs even if this `cd` fails (`cd x; rm ...`),
37   * so it then runs where the shell already was.
38   */
39  soft: boolean
40}
41
42export type Kind =
43  | 'rm'
44  | 'find-delete'
45  | 'xargs-rm'
46  | 'git-clean'
47  | 'git-reset-hard'
48  | 'git-discard'
49  | 'git-push-force'
50  | 'git-branch-delete'
51  | 'git-worktree-remove'
52  | 'redirect'
53
54export type GitInfo =
55  /** `forces`: 2 for `-ff`, which also removes nested git repositories. */
56  | { sub: 'clean'; flags: string[]; forces: number; excludes: string[]; paths: Word[] }
57  | { sub: 'reset'; ref?: Word }
58  | {
59      sub: 'discard'
60      /** `all`: every uncommitted change; `maybe-ref`: `git checkout x`, a branch or a path. */
61      mode: 'all' | 'paths' | 'maybe-ref'
62      source?: Word
63      /** True when the files come back from the index, so staged work survives. */
64      fromIndex: boolean
65      paths: Word[]
66    }
67  | { sub: 'push'; remote?: Word; refspecs: Word[]; isWholeRepo: boolean }
68  | { sub: 'branch'; names: Word[] }
69  | { sub: 'worktree'; paths: Word[] }
70
71export type Finding = {
72  kind: Kind
73  /** The command's own text, for the person to read. */
74  label: string
75  /** The `cd`s between the session's directory and where it runs. */
76  cd: CdStep[]
77  /** What it acts on: rm operands, find start points, the redirect's file. */
78  targets: Word[]
79  isSudo: boolean
80  git?: GitInfo
81  /** For find: a read-only argv that lists what it would delete. */
82  findListing?: string[]
83  /** Why it cannot be measured, when that is plain from the text alone. */
84  unknown?: string
85  /**
86   * The whole command is this one simple command and this redirect is all it
87   * does that we see: no `&&`, `||`, `;`, `|`, `&`, newline, subshell,
88   * `$(...)` or unquoted here-doc. Only then can "the target does not exist"
89   * be judged before anything runs.
90   */
91  isSole?: boolean
92}
93
94export type ClassifyEnv = { home?: string }
95
96/** How deep `$(...)`, `bash -c` and `eval` may nest before we stop looking. */
97const MAX_DEPTH = 6
98
99export function classify(command: string, env: ClassifyEnv = {}): Finding[] {
100  const out: Finding[] = []
101  const seq = parse(command)
102  walk(seq, command, [], new Map(), env, out, 0)
103  const only = out[0]
104  if (out.length === 1 && only?.kind === 'redirect' && isSoleCommand(seq)) only.isSole = true
105  return out
106}
107
108/** One simple command, nothing before or after it, nothing running inside it. */
109function isSoleCommand(seq: Seq): boolean {
110  const item = seq[0]
111  if (seq.length !== 1 || !item || item.kind !== 'simple' || (item.op !== '' && item.op !== ';')) return false
112  const words = [...item.words, ...item.redirs.flatMap(r => (r.target ? [r.target] : []))]
113  if (words.some(w => w.parts.some(p => p.t === 'sub'))) return false
114  if (item.redirs.some(r => (r.op === '<<' || r.op === '<<-') && !r.isQuotedDoc)) return false
115  return item.redirs.filter(r => r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&').length === 1
116}
117
118type Vars = Map<string, string | undefined>
119
120function walk(seq: Seq, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
121  let steps = cd
122  seq.forEach((item, index) => {
123    const before = seq[index - 1]?.op ?? ''
124    // A pipeline part and a `&` command run in a subshell: their `cd` stays there.
125    const isPiped = item.op === '|' || item.op === '|&' || item.op === '&' || before === '|' || before === '|&'
126    if (item.kind === 'group') {
127      const after = walk(item.body, src, steps, vars, env, out, depth)
128      redirects(item.redirs, item, src, steps, vars, env, out, depth)
129      if (!item.subshell && !isPiped) steps = after
130      return
131    }
132    const next = simple(item, src, steps, vars, env, out, depth)
133    if (!isPiped) steps = next
134  })
135  return steps
136}
137
138const RESERVED = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'esac', '!', 'time', '{', '}'])
139const HEADERS = new Set(['for', 'select', 'case', 'function'])
140const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
141
142/** Classifies one simple command; returns the `cd` steps after it. */
143function simple(item: Item & { kind: 'simple' }, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): CdStep[] {
144  const label = src.slice(item.start, item.end).trim()
145  const words = item.words.flatMap(w => resolveAll(w, src, vars, env))
146  // Commands inside the words run first, wherever the shell is now.
147  for (const w of item.words) subs(w, cd, vars, env, out, depth)
148  // Inside `[[ ... ]]` a `>` compares two strings; it writes no file.
149  const isTest = words[0]?.known === true && words[0].text === '[['
150  if (!isTest) redirects(item.redirs, item, src, cd, vars, env, out, depth)
151
152  let argv = words
153  while (argv[0] && RESERVED.has(argv[0].text) && argv[0].known) argv = argv.slice(1)
154  // `function f { body }`: the body is a command like any other.
155  if (argv[0]?.known && argv[0].text === 'function') {
156    argv = argv.slice(2)
157    while (argv[0]?.known && (argv[0].text === '{' || argv[0].text === '()')) argv = argv.slice(1)
158  }
159  const head = argv[0]
160  if (!head || !head.known) return cd
161  if (HEADERS.has(head.text)) return cd
162
163  // Assignments alone set shell variables; before a command they are its env.
164  const firstCommand = argv.findIndex(w => !isAssignment(w))
165  if (firstCommand === -1 || ['export', 'local', 'declare', 'readonly', 'typeset'].includes(head.text)) {
166    for (const w of argv) {
167      if (!isAssignment(w)) continue
168      const name = w.raw.slice(0, w.raw.indexOf('='))
169      vars.set(name, w.known ? w.text.slice(w.text.indexOf('=') + 1) : undefined)
170    }
171    return cd
172  }
173  argv = argv.slice(firstCommand)
174
175  const peeled = peel(argv)
176  argv = peeled.argv
177  const program = argv[0]
178  if (!program || !program.known) return cd
179  const name = base(program.text)
180  const steps = [...cd, ...peeled.cd]
181  const finding = (kind: Kind, rest: Partial<Finding>): void => {
182    out.push({ kind, label, cd: steps, targets: [], isSudo: peeled.isSudo, ...rest })
183  }
184
185  if (name === 'cd' || name === 'pushd') {
186    const operand = argv.slice(1).find(w => !(w.known && /^(-[LPe@]+|--)$/.test(w.text)))
187    const dir = operand ?? { text: env.home ?? '', known: env.home !== undefined, glob: false, raw: '~', plain: false }
188    if (dir.known && dir.text === '-') return [...cd, { dir: { ...dir, known: false }, soft: false }]
189    return [...cd, { dir, soft: item.op !== '&&' }]
190  }
191  if (name === 'popd') return [...cd, { dir: { text: '', known: false, glob: false, raw: 'popd', plain: false }, soft: false }]
192
193  if (peeled.viaXargs && name === 'rm') {
194    finding('xargs-rm', { unknown: 'it deletes whatever the command before it prints' })
195    return cd
196  }
197  if (SHELLS.has(name) && depth < MAX_DEPTH) {
198    const script = shellScript(argv)
199    if (script?.known) walk(parse(script.text), script.text, steps, new Map(vars), env, out, depth + 1)
200    return cd
201  }
202  if (name === 'eval' && depth < MAX_DEPTH) {
203    const text = argv.slice(1).map(w => w.text).join(' ')
204    if (argv.slice(1).every(w => w.known)) walk(parse(text), text, steps, new Map(vars), env, out, depth + 1)
205    return cd
206  }
207  if (name === 'rm') {
208    const rm = parseRm(argv)
209    if (rm.isRecursive && rm.operands.length > 0) finding('rm', { targets: rm.operands })
210  } else if (name === 'find') {
211    const found = parseFind(argv)
212    if (found) finding('find-delete', found)
213  } else if (name === 'git') {
214    const git = parseGit(argv, peeled.viaXargs)
215    if (git) {
216      const unknown = peeled.viaXargs ? 'more arguments come from the command before it' : git.finding.unknown
217      out.push({ label, cd: [...steps, ...git.cd], targets: [], isSudo: peeled.isSudo, ...git.finding, ...(unknown ? { unknown } : {}) })
218    }
219  }
220  return cd
221}
222
223function redirects(redirs: Redir[], item: Item, src: string, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
224  for (const r of redirs) {
225    if (!r.target || r.target.parts.length === 0) continue
226    subs(r.target, cd, vars, env, out, depth)
227    const overwrites = r.op === '>' || r.op === '>|' || r.op === '&>' || r.op === '>&'
228    if (!overwrites) continue
229    const target = resolve(r.target, src, vars, env)
230    if (target.known && (target.text.startsWith('/dev/') || target.text === '')) continue
231    out.push({
232      kind: 'redirect',
233      label: src.slice(item.start, item.end).trim(),
234      cd,
235      targets: [target],
236      isSudo: false,
237      ...(target.known ? {} : { unknown: `it writes to ${target.raw}, which is only known when it runs` }),
238    })
239  }
240}
241
242/** Walks the commands a word runs inside it (`$(...)`, backticks). */
243function subs(w: RawWord, cd: CdStep[], vars: Vars, env: ClassifyEnv, out: Finding[], depth: number): void {
244  if (depth >= MAX_DEPTH) return
245  for (const p of w.parts) {
246    if (p.t === 'sub') walk(parse(p.src), p.src, cd, new Map(vars), env, out, depth + 1)
247  }
248}
249
250// ---------------------------------------------------------------- words
251
252function resolve(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word {
253  let text = ''
254  let known = true
255  let glob = false
256  let plain = true
257  w.parts.forEach((p: Part, index) => {
258    if (p.t === 'lit') {
259      let s = p.s
260      if (index === 0 && !p.quoted && s.startsWith('~')) {
261        plain = false
262        const user = /^~([^/]*)/.exec(s)?.[1] ?? ''
263        if (user === '' && env.home !== undefined) s = env.home + s.slice(1)
264        else known = false
265      }
266      if (!p.quoted && /[*?[]/.test(s)) glob = true
267      // An unquoted brace may expand (or be refused) by the shell: not a plain path.
268      if (!p.quoted && /[{}]/.test(s)) plain = false
269      text += s
270    } else if (p.t === 'var') {
271      plain = false
272      const value = vars.has(p.name) ? vars.get(p.name) : p.name === 'HOME' ? env.home : undefined
273      // Unquoted, bash splits the value on spaces and globs it: one word no more.
274      if (value === undefined || (!p.quoted && /[\s*?[]/.test(value))) known = false
275      else text += value
276    } else {
277      plain = false
278      known = false
279    }
280  })
281  return { text, known, glob, raw: src.slice(w.start, w.end), plain: plain && !glob }
282}
283
284export function literalWord(text: string): Word {
285  return { text, known: true, glob: false, raw: text, plain: true }
286}
287
288/** Resolves a word, expanding braces (`{a,b}`, `{1..3}`) the way the shell would first. */
289function resolveAll(w: RawWord, src: string, vars: Vars, env: ClassifyEnv): Word[] {
290  const only = w.parts.length === 1 ? w.parts[0] : undefined
291  // Bash does not brace-expand an assignment (`X={a,b}`), so neither do we.
292  const isBraced = only?.t === 'lit' && !only.quoted && /\{[^{}]*(,|\.\.)[^{}]*\}/.test(only.s) && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(only.s)
293  if (!isBraced || only?.t !== 'lit') return [resolve(w, src, vars, env)]
294  const expanded = braces(only.s)
295  const raw = src.slice(w.start, w.end)
296  // Past the cap the word is unknown, so it is held, never cut short.
297  if (!expanded) return [{ text: only.s, known: false, glob: false, raw, plain: false }]
298  return expanded.map(s => ({ ...resolve({ ...w, parts: [{ t: 'lit', s, quoted: false }] }, src, vars, env), plain: false }))
299}
300
301/** Most words one brace expression may become, and most nested groups, before it counts as unknown. */
302const BRACE_CAP = 256
303const BRACE_GROUPS = 8
304
305/**
306 * Brace expansion with bounded work: comma lists and `{1..9}` / `{a..e}`
307 * ranges, generated one at a time; undefined once past the cap.
308 */
309export function braces(s: string): string[] | undefined {
310  const out: string[] = []
311  const expand = (text: string, depth: number): boolean => {
312    if (depth > BRACE_GROUPS) return false
313    const group = braceGroup(text)
314    if (!group) {
315      out.push(text)
316      return out.length <= BRACE_CAP
317    }
318    if (group.alternatives === undefined) return false
319    for (const alternative of group.alternatives) {
320      if (!expand(group.head + alternative + group.tail, depth + 1)) return false
321    }
322    return true
323  }
324  return expand(s, 0) ? out : undefined
325}
326
327/** The first `{...}` that expands: its alternatives (undefined: too many), and what is around it. */
328function braceGroup(s: string): { head: string; tail: string; alternatives: string[] | undefined } | undefined {
329  for (let open = s.indexOf('{'); open !== -1; open = s.indexOf('{', open + 1)) {
330    let depth = 0
331    const commas: number[] = []
332    for (let j = open; j < s.length; j++) {
333      if (s[j] === '{') depth++
334      else if (s[j] === '}') depth--
335      else if (s[j] === ',' && depth === 1) commas.push(j)
336      if (depth !== 0) continue
337      const head = s.slice(0, open)
338      const tail = s.slice(j + 1)
339      const body = s.slice(open + 1, j)
340      if (commas.length > 0) {
341        const cuts = [open, ...commas, j]
342        return { head, tail, alternatives: cuts.slice(1).map((cut, k) => s.slice((cuts[k] ?? open) + 1, cut)) }
343      }
344      const range = /^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])$/.exec(body)
345      if (range) return { head, tail, alternatives: sequence(range[1] ?? '', range[2] ?? '') }
346      break
347    }
348  }
349  return undefined
350}
351
352function sequence(from: string, to: string): string[] | undefined {
353  const isNumber = /\d/.test(from)
354  if (isNumber !== /\d/.test(to)) return undefined
355  const a = isNumber ? Number(from) : from.charCodeAt(0)
356  const b = isNumber ? Number(to) : to.charCodeAt(0)
357  if (Math.abs(b - a) >= BRACE_CAP) return undefined
358  const step = a <= b ? 1 : -1
359  const out: string[] = []
360  for (let n = a; step > 0 ? n <= b : n >= b; n += step) out.push(isNumber ? String(n) : String.fromCharCode(n))
361  return out
362}
363
364function isAssignment(w: Word): boolean {
365  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.raw)
366}
367
368export function base(path: string): string {
369  return path.slice(path.lastIndexOf('/') + 1)
370}
371
372// ---------------------------------------------------------------- prefixes
373
374type Peeled = { argv: Word[]; cd: CdStep[]; isSudo: boolean; viaXargs: boolean }
375
376/** Options of each prefix command that take a value of their own. */
377const VALUED: Record<string, readonly string[]> = {
378  sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U', '--user', '--group', '--chdir'],
379  doas: ['-u', '-C'],
380  env: ['-u', '-C', '-S', '-P', '--unset', '--chdir', '--split-string'],
381  nice: ['-n', '--adjustment'],
382  ionice: ['-c', '-n', '-p', '-P', '-u', '--class', '--classdata'],
383  stdbuf: ['-i', '-o', '-e', '--input', '--output', '--error'],
384  timeout: ['-s', '-k', '--signal', '--kill-after'],
385  exec: ['-a'],
386  xargs: ['-E', '-I', '-L', '-n', '-P', '-s', '-d', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--max-chars', '--max-lines', '--replace', '--eof'],
387}
388const PREFIXES = new Set([...Object.keys(VALUED), 'nohup', 'time', 'command', 'builtin', 'chronic', 'caffeinate', 'unbuffer'])
389
390/** Strips `sudo`, `env X=1`, `nohup`, `xargs` and kin down to the real program. */
391function peel(words: Word[]): Peeled {
392  let argv = words
393  const cd: CdStep[] = []
394  let isSudo = false
395  let viaXargs = false
396  for (let guard = 0; guard < 8; guard++) {
397    const head = argv[0]
398    if (!head?.known) break
399    const name = base(head.text)
400    if (!PREFIXES.has(name)) break
401    if (name === 'command' && argv.slice(1).some(w => w.text === '-v' || w.text === '-V')) return { argv: [], cd, isSudo, viaXargs }
402    if (name === 'sudo' || name === 'doas') isSudo = true
403    if (name === 'xargs') viaXargs = true
404    const valued = VALUED[name] ?? []
405    let k = 1
406    while (k < argv.length) {
407      const w = argv[k]
408      if (!w?.known) break
409      if (w.text === '--') {
410        k++
411        break
412      }
413      if (name === 'env' && isAssignment(w)) {
414        k++
415        continue
416      }
417      if (!w.text.startsWith('-') || w.text === '-') break
418      const flag = w.text.split('=')[0] ?? ''
419      const takesValue = valued.includes(flag) && !w.text.includes('=')
420      const value = takesValue ? argv[k + 1] : w.text.includes('=') ? { ...w, text: w.text.slice(w.text.indexOf('=') + 1) } : undefined
421      if ((name === 'env' && (flag === '-C' || flag === '--chdir')) || (name === 'sudo' && (flag === '-D' || flag === '--chdir'))) {
422        if (value) cd.push({ dir: value, soft: false })
423      }
424      k += takesValue ? 2 : 1
425    }
426    // `timeout 10 cmd`: the duration comes before the command.
427    if (name === 'timeout' && argv[k]?.known && /^[\d.]+[smhd]?$/.test(argv[k]?.text ?? '')) k++
428    argv = argv.slice(k)
429    // `sudo FOO=1 rm ...`: variables set for the command, not the command.
430    while (argv[0] && isAssignment(argv[0])) argv = argv.slice(1)
431  }
432  return { argv, cd, isSudo, viaXargs }
433}
434
435/** `bash -c 'script'`: the script, or undefined when there is no `-c`. */
436function shellScript(argv: Word[]): Word | undefined {
437  let hasC = false
438  for (let k = 1; k < argv.length; k++) {
439    const w = argv[k]
440    if (!w) break
441    if (w.known && /^-[a-zA-Z]+$/.test(w.text)) {
442      if (w.text.includes('c')) hasC = true
443      if (w.text === '-o' || w.text === '-O') k++
444      continue
445    }
446    if (w.known && w.text.startsWith('--')) continue
447    return hasC ? w : undefined
448  }
449  return undefined
450}
451
452// ---------------------------------------------------------------- rm
453
454export function parseRm(argv: Word[]): { isRecursive: boolean; operands: Word[] } {
455  let isRecursive = false
456  let isEnd = false
457  const operands: Word[] = []
458  for (const w of argv.slice(1)) {
459    const t = w.text
460    if (!isEnd && w.known && t === '--') isEnd = true
461    else if (!isEnd && w.known && t.startsWith('--')) {
462      // GNU takes any unique prefix of a long option: --rec, --recur.
463      if (t.length >= 3 && '--recursive'.startsWith(t)) isRecursive = true
464    } else if (!isEnd && w.known && t.startsWith('-') && t.length > 1) {
465      if (/[rR]/.test(t)) isRecursive = true
466    } else operands.push(w)
467  }
468  return { isRecursive, operands }
469}
470
471// ---------------------------------------------------------------- find
472
473const FIND_LEADING = new Set(['-H', '-L', '-P', '-E', '-X', '-s', '-x', '-d'])
474const FIND_VALUED = new Set([
475  '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-lname', '-ilname',
476  '-type', '-xtype', '-size', '-perm', '-user', '-group', '-uid', '-gid', '-maxdepth', '-mindepth', '-mtime',
477  '-atime', '-ctime', '-mmin', '-amin', '-cmin', '-newer', '-anewer', '-cnewer', '-samefile', '-inum', '-links',
478  '-fstype', '-used', '-context', '-printf', '-fprint', '-fprint0', '-fls', '-regextype', '-newermt', '-newerct',
479  '-newerat', '-newerBt',
480])
481const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
482/**
483 * Predicates that write files or run commands. Never copied into a measuring
484 * command: a find holding one is held unmeasured (GNU find opens -fprint
485 * files for writing while it parses, before it runs anything).
486 */
487export const FIND_WRITES = new Set(['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls'])
488/** Tests whose meaning differs between the system find and the shell's own (bfs, GNU). */
489const FIND_UNPORTABLE = new Set(['-regex', '-iregex', '-regextype', '-readable', '-writable', '-executable'])
490/** Actions that write or print on their own: a listing re-run would not be clean. */
491const FIND_SIDE_EFFECTS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fprint0', '-fprintf', '-fls', '-delete', '-print', '-print0', '-printf', '-ls'])
492
493export function parseFind(argv: Word[]): Partial<Finding> | undefined {
494  const args = argv.slice(1)
495  let k = 0
496  const leading: string[] = []
497  while (k < args.length) {
498    const t = args[k]?.text ?? ''
499    if (FIND_LEADING.has(t) || /^-O\d$/.test(t)) {
500      leading.push(t)
501      k++
502    } else if (t === '-D' || t === '-f') {
503      leading.push(t, args[k + 1]?.text ?? '')
504      k += 2
505    } else break
506  }
507  const starts: Word[] = []
508  while (k < args.length) {
509    const t = args[k]?.text ?? ''
510    if (args[k]?.known && (t.startsWith('-') || t === '(' || t === '!' || t === ')' || t === ',')) break
511    const w = args[k]
512    if (w) starts.push(w)
513    k++
514  }
515  const expr = args.slice(k)
516
517  // Scan the expression, skipping the values of tests that take one.
518  let deletes = false
519  let usesDelete = false
520  const listing: string[] = []
521  const isKnown = expr.every(w => w.known) && starts.every(w => w.known && !w.glob)
522  let isListable = true
523  let isPortable = true
524  for (let j = 0; j < expr.length; j++) {
525    const t = expr[j]?.text ?? ''
526    // Writers first: -fprint takes a value, but must never reach the listing.
527    if (t === '-fprint' || t === '-fprint0' || t === '-fprintf' || t === '-fls') {
528      isListable = false
529      j += t === '-fprintf' ? 2 : 1
530      continue
531    }
532    if (FIND_UNPORTABLE.has(t)) isPortable = false
533    if (FIND_VALUED.has(t)) {
534      listing.push(t, expr[j + 1]?.text ?? '')
535      j++
536      continue
537    }
538    if (t === '-delete') {
539      deletes = true
540      usesDelete = true
541      listing.push('-print0')
542      continue
543    }
544    if (FIND_EXEC.has(t)) {
545      let end = j + 1
546      while (end < expr.length && expr[end]?.text !== ';' && expr[end]?.text !== '+') end++
547      // `-exec sudo rm`, `-exec env rm`: peel the same prefixes as a command line.
548      const command = peel(expr.slice(j + 1, end)).argv
549      const program = command[0]?.known ? base(command[0].text) : ''
550      if (program === 'rm') {
551        deletes = true
552        listing.push('-print0')
553      } else if (SHELLS.has(program) || program === 'eval' || program === 'xargs' || !command[0]?.known) {
554        // A shell or a run-time program could delete anything: held unmeasured.
555        deletes = true
556        isListable = false
557      } else {
558        isListable = false
559      }
560      j = end
561      continue
562    }
563    if (FIND_SIDE_EFFECTS.has(t)) isListable = false
564    listing.push(t)
565  }
566  if (!deletes) return undefined
567  const from = starts.length > 0 ? starts : [{ text: '.', known: true, glob: false, raw: '.', plain: true }]
568  if (!isKnown) return { targets: from, unknown: 'it names paths that are only known when it runs' }
569  if (!isListable) return { targets: from, unknown: 'its find expression does more than delete, so it was not re-run to look' }
570  if (!isPortable) return { targets: from, unknown: 'it uses a find test the system find reads differently from the shell' }
571  // -delete turns on -depth, which also switches -prune off: list the same way.
572  return { targets: from, findListing: [...leading, ...from.map(w => w.text), ...(usesDelete ? ['-depth'] : []), ...listing] }
573}
574
575// ---------------------------------------------------------------- git
576
577/** Options of a branch switch that cannot discard anything. */
578const SWITCH_FLAGS = new Set(['-q', '--quiet', '-t', '--track', '--no-track', '--detach', '--progress', '--no-progress', '--guess', '--no-guess'])
579
580type GitParsed = { cd: CdStep[]; finding: Pick<Finding, 'kind' | 'git' | 'unknown'> }
581
582function shortFlags(t: string): string {
583  return /^-[A-Za-z]+$/.test(t) ? t.slice(1) : ''
584}
585
586export function parseGit(argv: Word[], viaXargs = false): GitParsed | undefined {
587  const cd: CdStep[] = []
588  let k = 1
589  let unknown: string | undefined
590  while (k < argv.length) {
591    const w = argv[k]
592    if (!w?.known || !w.text.startsWith('-')) break
593    if (w.text === '-C') {
594      const dir = argv[k + 1]
595      if (dir) cd.push({ dir, soft: false })
596      k += 2
597    } else if (w.text === '-c' || w.text === '--git-dir' || w.text === '--work-tree' || w.text === '--namespace') {
598      if (w.text !== '-c') unknown = 'it points git at another repository'
599      k += 2
600    } else {
601      if (w.text.startsWith('--git-dir=') || w.text.startsWith('--work-tree=')) unknown = 'it points git at another repository'
602      k++
603    }
604  }
605  const sub = argv[k]
606  if (!sub?.known) return undefined
607  const rest = argv.slice(k + 1)
608  const parsed = gitSub(sub.text, rest, viaXargs)
609  if (!parsed) return undefined
610  return { cd, finding: unknown ? { ...parsed, unknown } : parsed }
611}
612
613function gitSub(sub: string, rest: Word[], viaXargs: boolean): Pick<Finding, 'kind' | 'git'> | undefined {
614  const positionals: Word[] = []
615  const after: Word[] = []
616  let isEnd = false
617  const flags: string[] = []
618  for (let j = 0; j < rest.length; j++) {
619    const w = rest[j]
620    if (!w) continue
621    if (isEnd) after.push(w)
622    else if (w.known && w.text === '--') isEnd = true
623    else if (w.known && w.text.startsWith('-') && w.text !== '-') {
624      flags.push(w.text)
625      // Options whose value is a separate word.
626      const valued: Record<string, readonly string[]> = {
627        clean: ['-e', '--exclude'],
628        checkout: ['-b', '-B', '-c', '-C', '--orphan', '--conflict'],
629        restore: ['-s', '--source'],
630        push: ['-o', '--push-option', '--repo', '--receive-pack', '--exec'],
631        branch: ['-u', '--set-upstream-to', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort'],
632      }
633      if ((valued[sub] ?? []).includes(w.text)) {
634        const value = rest[j + 1]
635        if (value) flags.push(value.text)
636        j++
637      }
638    } else positionals.push(w)
639  }
640  const has = (...names: string[]) => flags.some(f => names.includes(f))
641  const hasShort = (ch: string) => flags.some(f => shortFlags(f).includes(ch))
642
643  if (sub === 'clean') {
644    const forces = flags.reduce((n, f) => n + (f === '--force' ? 1 : (shortFlags(f).match(/f/g) ?? []).length), 0)
645    if (forces === 0 || hasShort('n') || has('--dry-run')) return undefined
646    const keep = ['d', 'x', 'X'].filter(hasShort)
647    const excludes: string[] = []
648    flags.forEach((f, j) => {
649      if (f === '-e' || f === '--exclude') excludes.push(flags[j + 1] ?? '')
650      else if (f.startsWith('--exclude=')) excludes.push(f.slice('--exclude='.length))
651    })
652    return { kind: 'git-clean', git: { sub: 'clean', flags: keep, forces, excludes, paths: [...positionals, ...after] } }
653  }
654  if (sub === 'worktree') {
655    // `git worktree remove --force` deletes the worktree folder, uncommitted work and all.
656    const [action, ...targets] = [...positionals, ...after]
657    if (action?.text !== 'remove' || !(has('--force') || hasShort('f'))) return undefined
658    return { kind: 'git-worktree-remove', git: { sub: 'worktree', paths: targets } }
659  }
660  if (sub === 'reset') {
661    if (!has('--hard')) return undefined
662    const ref = positionals[0]
663    return { kind: 'git-reset-hard', git: ref ? { sub: 'reset', ref } : { sub: 'reset' } }
664  }
665  if (sub === 'checkout') {
666    const isForced = has('-f', '--force')
667    if (has('-p', '--patch')) return undefined
668    if (!isForced && has('-b', '-B', '-c', '-C', '--orphan')) return undefined
669    const discard = (mode: 'all' | 'paths' | 'maybe-ref', paths: Word[], source?: Word): Pick<Finding, 'kind' | 'git'> => ({
670      kind: 'git-discard',
671      git: { sub: 'discard', mode, paths, fromIndex: source === undefined, ...(source ? { source } : {}) },
672    })
673    if (isEnd) {
674      if (after.length > 0) return discard('paths', after, positionals[0])
675      return isForced ? discard('all', []) : undefined
676    }
677    if (isForced) return discard('all', [])
678    const [first, ...others] = positionals
679    if (!first || (first.known && first.text === '-')) return undefined
680    if (others.length > 0) return discard('paths', others, first)
681    // Only a bare `git checkout <plain name>` may turn out to be a branch switch
682    // (the measurer asks git). Any other option, or a name that is not a plain
683    // token, is taken as paths and held.
684    const isBare = flags.every(f => SWITCH_FLAGS.has(f.split('=')[0] ?? ''))
685    const isPath = !isBare || !first.plain || first.text === '.' || first.text.startsWith('./') || first.text.startsWith(':/')
686    return discard(isPath ? 'paths' : 'maybe-ref', [first])
687  }
688  if (sub === 'restore') {
689    const staged = hasShort('S') || has('--staged')
690    const worktree = hasShort('W') || has('--worktree')
691    if ((staged && !worktree) || hasShort('p') || has('--patch')) return undefined
692    const paths = [...positionals, ...after]
693    if (paths.length === 0) return undefined
694    const sourceAt = flags.findIndex(f => f === '-s' || f === '--source')
695    const sourceText = sourceAt >= 0 ? flags[sourceAt + 1] : flags.find(f => f.startsWith('--source='))?.slice('--source='.length)
696    const source = sourceText !== undefined ? literalWord(sourceText) : staged ? literalWord('HEAD') : undefined
697    return { kind: 'git-discard', git: { sub: 'discard', mode: 'paths', paths, fromIndex: source === undefined, ...(source ? { source } : {}) } }
698  }
699  if (sub === 'switch') {
700    if (!has('--discard-changes', '-f', '--force')) return undefined
701    return { kind: 'git-discard', git: { sub: 'discard', mode: 'all', paths: [], fromIndex: false } }
702  }
703  if (sub === 'push') {
704    const name = (f: string) => f.split('=')[0] ?? ''
705    // git takes a unique prefix of a long option: --force-with is the lease.
706    const isMirror = flags.some(f => name(f) === '--mirror')
707    const isForced = has('--force') || hasShort('f') || isMirror || [...positionals, ...after].slice(1).some(w => w.text.startsWith('+'))
708    // A lease guards only a push that is not also forced: `--force` (or -f,
709    // a `+refspec`, --mirror) switches the lease off, so it is held.
710    if (!isForced || has('--dry-run') || hasShort('n')) return undefined
711    const isWholeRepo = isMirror || has('--all', '--branches', '--tags')
712    const [remote, ...refspecs] = [...positionals, ...after]
713    return { kind: 'git-push-force', git: { sub: 'push', refspecs, isWholeRepo, ...(remote ? { remote } : {}) } }
714  }
715  if (sub === 'branch') {
716    const isDelete = hasShort('d') || hasShort('D') || has('--delete')
717    const isForced = hasShort('D') || hasShort('f') || has('--force')
718    if (!isDelete || !isForced || hasShort('r') || has('--remotes')) return undefined
719    // Names after `--` are names too: `git branch -D -- feature`.
720    const names = [...positionals, ...after]
721    return names.length > 0 || viaXargs ? { kind: 'git-branch-delete', git: { sub: 'branch', names } } : undefined
722  }
723  return undefined
724}
725
726export { literal }
727
hooks/plan.ts 218 lines
1// The pure part of mulligan: from what the classifier found in a command, what
2// a snapshot must hold so /mulligan can put the project back; and the words.
3//
4// A positive list of shapes, never "everything not matched is safe": the
5// classifier names the commands that destroy files (rm, find -delete, xargs
6// rm, git clean, git reset --hard, git checkout or restore over changes,
7// git worktree remove, a redirect over a file). Pushes and branch deletions
8// touch no file here and take no snapshot.
9
10import type { CdStep, Finding, Word } from './classify'
11import { join, normalize, within } from './paths'
12
13/** What one snapshot holds. */
14export type Scope = {
15  /** Every file under the project (git: tracked changes and untracked files). */
16  isTree: boolean
17  /** git: the tracked files' state, through `git stash create` (HEAD when nothing changed). */
18  isTracked: boolean
19  /** git: untracked files that are not ignored. */
20  isUntracked: boolean
21  /** Paths under the project the command acts on, absolute, none inside another. */
22  paths: string[]
23}
24
25export type Plan = {
26  scope: Scope
27  /** Targets outside the project, which no snapshot covers. */
28  outside: string[]
29  /** What the snapshot leaves out, in words (ignored files under git clean -x). */
30  notes: string[]
31}
32
33export const EMPTY_SCOPE: Scope = { isTree: false, isTracked: false, isUntracked: false, paths: [] }
34
35/** True when the scope holds nothing to save. */
36export function isEmpty(scope: Scope): boolean {
37  return !scope.isTree && !scope.isTracked && !scope.isUntracked && scope.paths.length === 0
38}
39
40/** Where a finding runs: the shell's directory through its `cd`s, or undefined when a `cd` is unknown ahead. */
41export function whereItRuns(cwd: string, steps: readonly CdStep[]): string | undefined {
42  let dir = cwd
43  for (const step of steps) {
44    if (!step.dir.known) return undefined
45    dir = join(dir, step.dir.text)
46  }
47  return dir
48}
49
50/**
51 * The part of a path written before its first pattern: `build/*` is `build`, `**` is nothing.
52 * Undefined when the word holds an expansion nobody can know ahead.
53 */
54export function staticPart(word: Word): string | undefined {
55  if (!word.known) return undefined
56  if (!word.glob) return word.text
57  const segments: string[] = []
58  for (const segment of word.text.split('/')) {
59    if (/[*?[{]/.test(segment)) break
60    segments.push(segment)
61  }
62  return segments.join('/')
63}
64
65/**
66 * What to save before `findings` run from the shell's directory `cwd`, in a project at `root`.
67 * Null when nothing in the command touches files (no finding, or only pushes and branch deletes).
68 */
69export function planFor(findings: readonly Finding[], cwd: string, root: string, isGit: boolean): Plan | null {
70  const scope: Scope = { ...EMPTY_SCOPE, paths: [] }
71  const outside: string[] = []
72  const notes: string[] = []
73  let isFiles = false
74  const add = (dir: string | undefined, target: Word) => {
75    const part = dir === undefined ? undefined : staticPart(target)
76    if (dir === undefined || part === undefined || part === '') {
77      scope.isTree = true // where or what is only known when it runs: save the whole project
78      return
79    }
80    const path = join(dir, part)
81    if (!within(root, path)) outside.push(path)
82    else if (path === normalize(root)) scope.isTree = true
83    else scope.paths.push(path)
84  }
85  for (const f of findings) {
86    const dir = whereItRuns(cwd, f.cd)
87    switch (f.kind) {
88      case 'rm':
89      case 'find-delete':
90      case 'redirect':
91        isFiles = true
92        if (f.targets.length === 0) scope.isTree = true
93        for (const target of f.targets) add(dir, target)
94        break
95      case 'xargs-rm':
96        isFiles = true
97        scope.isTree = true // it deletes whatever the command before it prints
98        break
99      case 'git-worktree-remove':
100        isFiles = true
101        for (const target of f.git?.sub === 'worktree' ? f.git.paths : []) add(dir, target)
102        break
103      case 'git-clean':
104        isFiles = true
105        if (dir !== undefined && !within(root, dir)) outside.push(dir) // git -C another repository
106        else if (isGit) scope.isUntracked = true
107        else scope.isTree = true
108        if (f.git?.sub === 'clean' && f.git.flags.some(flag => /x/i.test(flag.replace(/^--?/, '')))) {
109          notes.push('ignored files (git clean -x) are not saved')
110        }
111        break
112      case 'git-reset-hard':
113      case 'git-discard':
114        isFiles = true
115        if (dir !== undefined && !within(root, dir)) outside.push(dir)
116        else if (isGit) scope.isTracked = true
117        else scope.isTree = true
118        break
119      case 'git-push-force':
120      case 'git-branch-delete':
121        break // no file in the project changes
122    }
123  }
124  if (!isFiles) return null
125  if (scope.isTree) {
126    scope.paths = []
127    if (isGit) {
128      scope.isTracked = true
129      scope.isUntracked = true
130    }
131  }
132  scope.paths = outermost(scope.paths)
133  return { scope, outside: [...new Set(outside)], notes }
134}
135
136/** The whole project, for the person's own `always` pattern. */
137export function treePlan(isGit: boolean): Plan {
138  return { scope: { isTree: true, isTracked: isGit, isUntracked: isGit, paths: [] }, outside: [], notes: [] }
139}
140
141/** Paths with every one inside another dropped: `build` covers `build/x`. */
142export function outermost(paths: readonly string[]): string[] {
143  const sorted = [...new Set(paths)].sort()
144  return sorted.filter(path => !sorted.some(other => other !== path && within(other, path)))
145}
146
147/** A path as the person reads it: relative to the project, else as it is. */
148export function shown(path: string, root: string): string {
149  if (path === root) return '.'
150  return within(root, path) ? path.slice(root === '/' ? 1 : root.length + 1) : path
151}
152
153/** What a snapshot holds, in a few words: "build, dist", "tracked changes and untracked files", "the project". */
154export function scopeWords(scope: Scope, root: string): string {
155  if (scope.isTree) return 'the project'
156  const parts: string[] = []
157  if (scope.isTracked) parts.push('tracked changes')
158  if (scope.isUntracked) parts.push('untracked files')
159  if (scope.paths.length > 0) {
160    const names = scope.paths.map(path => shown(path, root))
161    parts.push(names.length <= 3 ? names.join(', ') : `${names.slice(0, 3).join(', ')} and ${names.length - 3} more`)
162  }
163  return parts.join(' and ')
164}
165
166/** One line for a drawing: whitespace runs as one space, at most `max` characters. */
167export function oneLine(text: string, max: number): string {
168  const flat = text.replace(/\s+/g, ' ').trim()
169  return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
170}
171
172/** Control characters and escape sequences out of anything drawn (commands and paths are Claude's). */
173export function clean(text: string): string {
174  return text
175    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
176    .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
177    .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '�')
178}
179
180/** 12345678 bytes as "12 MB". */
181export function sizeWords(bytes: number): string {
182  if (bytes < 1_000) return `${bytes} B`
183  if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`
184  return `${Math.round(bytes / 100_000) / 10} MB`
185}
186
187/** "3m ago", "2h ago", for the list. */
188export function agoWords(ms: number): string {
189  const minutes = Math.floor(ms / 60_000)
190  if (minutes < 1) return 'just now'
191  if (minutes < 60) return `${minutes}m ago`
192  const hours = Math.floor(minutes / 60)
193  return hours < 48 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
194}
195
196/** FNV-1a over the project root: a short key for its folder that does not spell the path. */
197export function projectKey(root: string): string {
198  let h = 0x811c9dc5
199  for (let i = 0; i < root.length; i++) {
200    h ^= root.charCodeAt(i)
201    h = Math.imul(h, 0x01000193) >>> 0
202  }
203  let g = 0x01000193
204  for (let i = root.length - 1; i >= 0; i--) {
205    g ^= root.charCodeAt(i)
206    g = Math.imul(g, 0x811c9dc5) >>> 0
207  }
208  return h.toString(16).padStart(8, '0') + g.toString(16).padStart(8, '0')
209}
210
211/** The note Claude reads after a restore: its view of the files is stale. */
212export function restoreNote(command: string, n: number): string {
213  return (
214    `The person restored files in this project to how they were before the command \`${oneLine(command, 200)}\` ` +
215    `(mulligan snapshot ${n}). Files may differ from what you last read: re-read any file before editing it.`
216  )
217}
218
hooks/shell.ts 405 lines
1// The same file as last-look's hooks/shell.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// A small, forgiving shell parser: just enough of bash's grammar to see which
5// commands a string runs, with which words and redirects, in which order.
6//
7// It never throws and always ends: on input bash would reject (an open quote,
8// a stray paren) it keeps going with its best reading, because a guard that
9// stops at a parse error would let the rest of the string through unread.
10
11/** One piece of a word, as written. */
12export type Part =
13  | { t: 'lit'; s: string; quoted: boolean }
14  /** `quoted`: inside double quotes, so the shell neither splits nor globs its value. */
15  | { t: 'var'; name: string; quoted: boolean }
16  /** `$( ... )`, backticks or `<( ... )`: a command of its own. */
17  | { t: 'sub'; src: string }
18  /** An expansion nobody can know ahead (`$1`, `$((n+1))`, `${x:-y}`). */
19  | { t: 'opaque' }
20
21export type RawWord = { parts: Part[]; start: number; end: number }
22
23export type Redir = {
24  op: string
25  fd?: string
26  /** The file it names; absent for a here-doc or a descriptor copy (`2>&1`). */
27  target?: RawWord
28  /** For a here-doc: its delimiter was quoted, so its body is plain data (no `$(...)` runs). */
29  isQuotedDoc?: boolean
30}
31
32export type Simple = {
33  kind: 'simple'
34  words: RawWord[]
35  redirs: Redir[]
36  start: number
37  end: number
38}
39
40export type Group = {
41  kind: 'group'
42  /** `( ... )` runs in a subshell; `{ ...; }` does not, so its `cd` sticks. */
43  subshell: boolean
44  body: Seq
45  redirs: Redir[]
46  start: number
47  end: number
48}
49
50/** A command and the operator after it: `&&`, `||`, `;`, `|`, `&`, or ``. */
51export type Item = (Simple | Group) & { op: string }
52export type Seq = Item[]
53
54const BLANK = new Set([' ', '\t', '\r'])
55const ENDS_WORD = new Set([' ', '\t', '\r', '\n', ';', '&', '|', '(', ')', '<', '>'])
56const REDIRECT = /^(\d*)(&>>|&>|>>|>\||>&|<<<|<<-|<<|<>|<&|>|<)/
57
58export function parse(src: string): Seq {
59  return new Parser(src).seq(false)
60}
61
62class Parser {
63  private i = 0
64  private heredocs: { delim: string; strip: boolean }[] = []
65
66  constructor(private readonly src: string) {}
67
68  seq(close: ')' | '}' | false): Seq {
69    const items: Seq = []
70    while (true) {
71      this.blank()
72      if (this.i >= this.src.length) return items
73      const c = this.src[this.i]
74      if (c === '\n') {
75        this.newline()
76        continue
77      }
78      if (c === ')') {
79        this.i++
80        if (close === ')') return items
81        continue
82      }
83      if (c === ';' || c === '&' || c === '|') {
84        this.i++
85        continue
86      }
87      if (close === '}' && this.atWord('}')) {
88        this.i++
89        return items
90      }
91      const node = this.command()
92      this.blank()
93      items.push({ ...node, op: this.operator() })
94    }
95  }
96
97  private command(): Simple | Group {
98    const start = this.i
99    if (this.src.startsWith('((', this.i)) {
100      // Arithmetic `(( n > 2 ))`: its `>` compares, it redirects nothing.
101      this.i = this.balanced(this.i)
102      return { kind: 'simple', words: [], redirs: [], start, end: this.i }
103    }
104    if (this.src[this.i] === '(') {
105      this.i++
106      const body = this.seq(')')
107      return { kind: 'group', subshell: true, body, redirs: this.redirects(), start, end: this.i }
108    }
109    if (this.atWord('{')) {
110      this.i++
111      const body = this.seq('}')
112      return { kind: 'group', subshell: false, body, redirs: this.redirects(), start, end: this.i }
113    }
114    return this.simple()
115  }
116
117  private simple(): Simple {
118    const start = this.i
119    const words: RawWord[] = []
120    const redirs: Redir[] = []
121    while (true) {
122      this.blank()
123      const c = this.src[this.i]
124      if (c === undefined || c === '\n' || c === ';' || c === '|' || c === ')' || c === '(') break
125      if (c === '&' && this.src[this.i + 1] !== '>') break
126      if ((c === '<' || c === '>') && this.src[this.i + 1] === '(') {
127        words.push(this.processSub())
128        continue
129      }
130      const redir = this.redirect()
131      if (redir) {
132        redirs.push(redir)
133        continue
134      }
135      const before = this.i
136      const word = this.word()
137      if (this.i === before) {
138        this.i++
139        continue
140      }
141      words.push(word)
142    }
143    return { kind: 'simple', words, redirs, start, end: this.i }
144  }
145
146  /** Redirects after a group's closing paren or brace. */
147  private redirects(): Redir[] {
148    const out: Redir[] = []
149    while (true) {
150      this.blank()
151      const redir = this.redirect()
152      if (!redir) return out
153      out.push(redir)
154    }
155  }
156
157  private redirect(): Redir | undefined {
158    const m = REDIRECT.exec(this.src.slice(this.i, this.i + 8))
159    if (!m) return undefined
160    const fd = m[1] === '' ? undefined : m[1]
161    const op = m[2] ?? ''
162    this.i += m[0].length
163    this.blank()
164    if (op === '<<' || op === '<<-') {
165      const delim = this.word()
166      this.heredocs.push({ delim: literal(delim), strip: op === '<<-' })
167      return { op, fd, isQuotedDoc: delim.parts.some(p => p.t === 'lit' && p.quoted) }
168    }
169    const target = this.word()
170    if ((op === '>&' || op === '<&') && /^(\d+|-)$/.test(literal(target))) return { op, fd }
171    return { op, fd, target }
172  }
173
174  private operator(): string {
175    const two = this.src.slice(this.i, this.i + 2)
176    if (two === '&&' || two === '||' || two === '|&' || two === ';;') {
177      this.i += 2
178      return two === ';;' ? ';' : two
179    }
180    const c = this.src[this.i]
181    if (c === ';' || c === '|' || c === '&') {
182      this.i++
183      return c
184    }
185    if (c === '\n') {
186      this.newline()
187      return ';'
188    }
189    return ''
190  }
191
192  /** Is the next word exactly `w`, unquoted and standing alone? */
193  private atWord(w: string): boolean {
194    if (!this.src.startsWith(w, this.i)) return false
195    const after = this.src[this.i + w.length]
196    return after === undefined || ENDS_WORD.has(after)
197  }
198
199  /** Spaces, tabs, line continuations and comments; never a newline. */
200  private blank(): void {
201    while (this.i < this.src.length) {
202      const c = this.src[this.i]
203      if (c !== undefined && BLANK.has(c)) this.i++
204      else if (c === '\\' && this.src[this.i + 1] === '\n') this.i += 2
205      else if (c === '#') {
206        while (this.i < this.src.length && this.src[this.i] !== '\n') this.i++
207      } else return
208    }
209  }
210
211  /** Consumes a newline, then the bodies of any here-docs opened on its line. */
212  private newline(): void {
213    this.i++
214    for (const doc of this.heredocs) {
215      while (this.i < this.src.length) {
216        const cut = this.src.indexOf('\n', this.i)
217        const lineEnd = cut === -1 ? this.src.length : cut
218        const line = this.src.slice(this.i, lineEnd)
219        this.i = lineEnd + 1
220        if ((doc.strip ? line.replace(/^\t+/, '') : line) === doc.delim) break
221      }
222    }
223    this.heredocs = []
224    if (this.i > this.src.length) this.i = this.src.length
225  }
226
227  private word(): RawWord {
228    const start = this.i
229    const parts: Part[] = []
230    let lit = ''
231    const flush = () => {
232      if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: false })
233      lit = ''
234    }
235    while (this.i < this.src.length) {
236      const c = this.src[this.i] ?? ''
237      if (ENDS_WORD.has(c)) break
238      if (c === '\\') {
239        const next = this.src[this.i + 1]
240        this.i += 2
241        if (next === undefined || next === '\n') continue
242        flush()
243        parts.push({ t: 'lit', s: next, quoted: true })
244      } else if (c === "'") {
245        flush()
246        const close = this.src.indexOf("'", this.i + 1)
247        const end = close === -1 ? this.src.length : close
248        parts.push({ t: 'lit', s: this.src.slice(this.i + 1, end), quoted: true })
249        this.i = end + 1
250      } else if (c === '"') {
251        flush()
252        this.i++
253        this.doubleQuoted(parts)
254      } else if (c === '$') {
255        flush()
256        this.dollar(parts, false)
257      } else if (c === '`') {
258        flush()
259        parts.push({ t: 'sub', src: this.backtick() })
260      } else {
261        lit += c
262        this.i++
263      }
264    }
265    flush()
266    if (this.i > this.src.length) this.i = this.src.length
267    return { parts, start, end: this.i }
268  }
269
270  private doubleQuoted(parts: Part[]): void {
271    let lit = ''
272    const flush = () => {
273      if (lit !== '') parts.push({ t: 'lit', s: lit, quoted: true })
274      lit = ''
275    }
276    while (this.i < this.src.length) {
277      const c = this.src[this.i] ?? ''
278      if (c === '"') {
279        this.i++
280        break
281      }
282      if (c === '\\') {
283        const next = this.src[this.i + 1] ?? ''
284        if (next === '\n') this.i += 2
285        else if ('$`"\\'.includes(next) && next !== '') {
286          lit += next
287          this.i += 2
288        } else {
289          lit += c
290          this.i++
291        }
292      } else if (c === '$') {
293        flush()
294        this.dollar(parts, true)
295      } else if (c === '`') {
296        flush()
297        parts.push({ t: 'sub', src: this.backtick() })
298      } else {
299        lit += c
300        this.i++
301      }
302    }
303    flush()
304  }
305
306  private dollar(parts: Part[], inQuotes: boolean): void {
307    const next = this.src[this.i + 1] ?? ''
308    if (next === '(') {
309      if (this.src[this.i + 2] === '(') {
310        this.i = this.balanced(this.i + 1)
311        parts.push({ t: 'opaque' })
312        return
313      }
314      const end = this.balanced(this.i + 1)
315      parts.push({ t: 'sub', src: this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1)) })
316      this.i = end
317    } else if (next === '{') {
318      const close = this.src.indexOf('}', this.i + 2)
319      const end = close === -1 ? this.src.length : close
320      const inner = this.src.slice(this.i + 2, end)
321      parts.push(/^[A-Za-z_][A-Za-z0-9_]*$/.test(inner) ? { t: 'var', name: inner, quoted: inQuotes } : { t: 'opaque' })
322      this.i = end + 1
323    } else if (/[A-Za-z_]/.test(next)) {
324      const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i + 1)) ?? ['']
325      parts.push({ t: 'var', name: m[0], quoted: inQuotes })
326      this.i += 1 + m[0].length
327    } else if (/[0-9@*#?$!-]/.test(next) && next !== '') {
328      parts.push({ t: 'opaque' })
329      this.i += 2
330    } else if (next === "'" && !inQuotes) {
331      // $'...': ANSI-C quoting; the common escapes are enough here.
332      let s = ''
333      let j = this.i + 2
334      while (j < this.src.length && this.src[j] !== "'") {
335        if (this.src[j] === '\\' && j + 1 < this.src.length) {
336          const e = this.src[j + 1] ?? ''
337          s += e === 'n' ? '\n' : e === 't' ? '\t' : e
338          j += 2
339        } else s += this.src[j++]
340      }
341      parts.push({ t: 'lit', s, quoted: true })
342      this.i = j + 1
343    } else if (next === '"' && !inQuotes) {
344      this.i += 2
345      this.doubleQuoted(parts)
346    } else {
347      parts.push({ t: 'lit', s: '$', quoted: inQuotes })
348      this.i++
349    }
350  }
351
352  /** From an opening paren at `open`, the index just past its partner. */
353  private balanced(open: number): number {
354    let depth = 0
355    let j = open
356    while (j < this.src.length) {
357      const c = this.src[j]
358      if (c === '\\') j += 2
359      else if (c === "'") {
360        const close = this.src.indexOf("'", j + 1)
361        j = close === -1 ? this.src.length : close + 1
362      } else if (c === '"') {
363        j++
364        while (j < this.src.length && this.src[j] !== '"') j += this.src[j] === '\\' ? 2 : 1
365        j++
366      } else if (c === '(') {
367        depth++
368        j++
369      } else if (c === ')') {
370        depth--
371        j++
372        if (depth === 0) return j
373      } else j++
374    }
375    return this.src.length
376  }
377
378  private backtick(): string {
379    let j = this.i + 1
380    let s = ''
381    while (j < this.src.length && this.src[j] !== '`') {
382      if (this.src[j] === '\\' && j + 1 < this.src.length) {
383        s += this.src[j + 1]
384        j += 2
385      } else s += this.src[j++]
386    }
387    this.i = j + 1
388    return s
389  }
390
391  /** `<( ... )` or `>( ... )`: a command whose output stands in as a file. */
392  private processSub(): RawWord {
393    const start = this.i
394    const end = this.balanced(this.i + 1)
395    const src = this.src.slice(this.i + 2, Math.max(this.i + 2, end - 1))
396    this.i = end
397    return { parts: [{ t: 'sub', src }], start, end }
398  }
399}
400
401/** A word's text when it holds no expansion; quotes removed. */
402export function literal(w: RawWord): string {
403  return w.parts.map(p => (p.t === 'lit' ? p.s : '')).join('')
404}
405
hooks/paths.ts 94 lines
1// The same file as last-look's hooks/paths.ts, copied so this mod installs on its own.
2// Keep the two in step: a fix to one is a fix to both.
3
4// Pure POSIX path helpers, and the alwaysAllow match.
5
6export function isAbsolute(path: string): boolean {
7  return path.startsWith('/')
8}
9
10/** Folds `.`, `..` and repeated slashes; keeps a lone `/`. */
11export function normalize(path: string): string {
12  const parts: string[] = []
13  for (const part of path.split('/')) {
14    if (part === '' || part === '.') continue
15    if (part === '..') parts.pop()
16    else parts.push(part)
17  }
18  return `/${parts.join('/')}`
19}
20
21export function join(dir: string, path: string): string {
22  return normalize(isAbsolute(path) ? path : `${dir}/${path}`)
23}
24
25/** True when `path` is `parent` or lies beneath it. */
26export function within(parent: string, path: string): boolean {
27  if (parent === '/') return true
28  return path === parent || path.startsWith(`${parent}/`)
29}
30
31/** Moves `path` from beneath `from` to beneath `to`, or leaves it as it is. */
32export function rebase(path: string, from: string, to: string): string {
33  if (!within(from, path)) return path
34  return normalize(to + path.slice(from.length))
35}
36
37/** How a path reads to the person: relative to where they are, else `~/...`. */
38export function show(path: string, cwd: string, home?: string): string {
39  if (path === cwd) return '.'
40  if (within(cwd, path)) return path.slice(cwd === '/' ? 1 : cwd.length + 1)
41  if (home && within(home, path)) return path === home ? '~' : `~${path.slice(home.length)}`
42  return path
43}
44
45/** The alwaysAllow setting: a comma list of folders, `./` and trailing `/` dropped. */
46export function parseAllow(setting: string): string[] {
47  return setting
48    .split(',')
49    .map(entry => entry.trim().replace(/^(\.\/)+/, '').replace(/\/+$/, ''))
50    .filter(entry => entry !== '' && entry !== '.' && !entry.split('/').includes('..'))
51}
52
53/**
54 * True when `path` lies inside an allowed folder: an absolute entry by
55 * prefix; a relative one (`dist`, `node_modules/.cache`) as a run of folder
56 * names anywhere beneath the project root, never the root itself or outside.
57 */
58export function isAllowed(path: string, root: string, allow: readonly string[]): boolean {
59  return allow.some(entry => {
60    if (isAbsolute(entry)) return within(normalize(entry), path)
61    if (!within(root, path) || path === root) return false
62    const segments = path.slice(root === '/' ? 1 : root.length + 1).split('/')
63    const wanted = entry.split('/')
64    for (let start = 0; start + wanted.length <= segments.length; start++) {
65      if (wanted.every((name, k) => segments[start + k] === name)) return true
66    }
67    return false
68  })
69}
70
71/** A glob segment (`*.log`, `b?ild`, `[ab]*`) as a RegExp, as bash matches names. */
72export function globSegment(segment: string): RegExp {
73  let source = ''
74  for (let k = 0; k < segment.length; k++) {
75    const c = segment[k] ?? ''
76    if (c === '*') source += '[^/]*'
77    else if (c === '?') source += '[^/]'
78    else if (c === '[') {
79      const close = segment.indexOf(']', k + 2)
80      if (close === -1) source += '\\['
81      else {
82        const body = segment.slice(k + 1, close).replace(/^!/, '^').replace(/\\/g, '\\\\')
83        source += `[${body}]`
84        k = close
85      }
86    } else source += c.replace(/[.+^${}()|\\\]]/g, '\\$&')
87  }
88  return new RegExp(`^${source}$`)
89}
90
91export function hasGlob(segment: string): boolean {
92  return /[*?[]/.test(segment)
93}
94
types/index.d.ts 38 lines
1// What mulligan keeps in $.state for the session: the project's snapshots as
2// last read from its folder, the line above the prompt, and what the pane
3// shows. The snapshots themselves are files under ~/.claude/mulligan.
4
5/** One snapshot, saved before a destructive command ran. */
6export type MulliganSnapshot = {
7  /** Its number in this project, from 1. */
8  n: number
9  /** When it was saved, ms since the epoch. */
10  at: number
11  /** The command it was saved before, cleaned, one line. */
12  command: string
13  /** What it holds, in words ("dist", "tracked changes and untracked files", "the project"). */
14  scope: string
15  /** git: the commit object holding the tracked files' state. */
16  sha?: string
17  /** The archive's file name in the project's folder, when files were copied. */
18  archive?: string
19  /** The archive's size in bytes (0 without one). */
20  bytes: number
21}
22
23/** The line above the prompt after a snapshot, or a skip, until a moment passes. */
24export type MulliganBand = { text: string; tone: 'subtle' | 'warning'; until: number }
25
26/** What the pane shows: the list (with a restore waiting on its confirm), or one snapshot's diff. */
27export type MulliganView = { kind: 'list'; confirm: number } | { kind: 'diff'; n: number; lines: string[] }
28
29declare module 'claude-code' {
30  interface PluginState {
31    mulligan: {
32      snapshots: MulliganSnapshot[]
33      band: MulliganBand | null
34      view: MulliganView
35    }
36  }
37}
38