SLOPSHOPPER

command-guard

Holds risky Bash commands (rm -rf, git reset --hard, force push, DROP TABLE ...), shows exactly what they would destroy, and asks first. A safety net, not a…

newguardcommandtoaststatusprocess
★ 1v0.3.3MITupdated 2026-10-14samuelbfernandes/moddeck/mods/command-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ command-guard │ ⏺ Read(src/auth.ts) │ Blocked rm -rf build · nothing exists at │ ⎿ Read 6 lines │ that path right now │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by command-guard: Blocked by command-guard (cancelled in the dialog). Not run: rm -rf build && git ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /command-guard ⎿ command-guard: Blast radius · medium (ask from medium risk up) ⎿ command-guard: Levels: off = never ask · low = ask for everything, even low risk · medium = ask from medium risk up · high = ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ command-guard: Blocked rm -rf build · nothing exists at that path right now
README

command-guard

Install (Claude Code 2.1.287 or later): /plugin install command-guard --marketplace samuelbfernandes/moddeck

What it does

Holds risky Bash commands and shows what they would destroy before they run. The question opens with one line, the risk as a plain word and what is about to happen, then the command, a few short rows and two answers: Cancel (first, so Enter means no) and Proceed. Cancel, Esc, free text, a dismissed dialog or a headless run all refuse, and the model is told why. A safety net, not a permission system: it only adds a question and never approves anything; keep your normal permissions on.

🔴 High risk · deletes data/
rm -rf data

Where        Inside repository /Users/sam/thesis
Contains     2 data files (211 MB) · 1 likely secret file (4 KB) · 1 source file
Size         212 MB · 4 files · last edit within 6 hours · likely active work
Git          4 untracked files
At stake     raw.csv (207 MB), fit.rds (3.9 MB), .env (4 KB)
Recoverable  No · untracked files cannot be restored

Estimated from file names and your last fetch.

Run this command?

Other commands read the same way:

🔴 High risk · discards uncommitted changes in 3 files
git reset --hard origin/main

Impact       Uncommitted changes to 3 tracked files, gone for good (not in the reflog)
Changes lost
  M  R/a.R
  D  b.R
  M  c.R
Total        3 files changed, 10 insertions(+), 4 deletions(-)
Note         Untracked files are not touched

Run this command?

The level is High, Medium, Low or Unknown (Unknown: something could not be checked, and the row says what). Lists show a few rows and "+N more". A line with several risky commands gets one short section each.

Held: rm -r/-f (also rm *.csv, xargs rm), find -delete|-exec rm, git clean -f, reset --hard, checkout ./restore ., force push, branch -D, stash drop|clear, truncate, shred, dd of=, mkfs, chmod|chown -R, cargo clean, mv|cp -f onto existing files or folders, > file over a non-empty file, SQL DROP/TRUNCATE/DELETE without WHERE, and R -e unlink(recursive=TRUE). It sees through sudo, quoting, ; && pipes, bash -c, $(...) and cd /abs && ....

Folder rows

For a folder a held command would delete, overwrite or change recursively, the dialog adds these rows (up to 3 folders, the most dangerous first; with several folders each gets its own short block):

  • Target: only for the places that are never routine: /, your home, Desktop/Documents/Downloads, the working directory or a parent of it. Those are always High.
  • Where: the root of a git repository (its history goes too), a folder inside one, or outside git. Local only adds commits on no remote, stashes and linked worktrees when a repository root would go.
  • Contains, guessed from file names only: build/cache output (node_modules, target, build, dist, __pycache__, .venv, renv/library, *.Rcheck, .Rproj.user ...), source, data that is hard to regenerate (csv, rds, vcf, bam, parquet, xlsx, pdf, tex ...), likely secrets (.env, *.pem, id_*).
  • Size: bytes, file count and the last edit ("within 6 hours" is flagged as likely active work).
  • Git: pushed, not pushed, modified, untracked, ignored. At stake names the biggest hard-to-regenerate files.
  • Note: synced folders (iCloud Drive, Dropbox, OneDrive, Google Drive), symlinks (rm removes the link, not its target), another filesystem inside.
  • Recoverable: Yes, Partly, No or Unknown, with the reason. It sets the level: only build output or a fully pushed folder is Low; irreplaceable data or unpushed work is High.

Settings and turning off

/plugin > command-guard: askFrom (low, medium default, high: the lowest risk level that opens the dialog; at the default a low-risk command runs without one, and unknown always asks), maxListed (rows shown under a list, default 5), extraPatterns (regexes; a matching command is held, nothing measured). Turn off: /plugin disable command-guard.

The level carries a coloured dot, since the dialog takes plain text only: 🔴 high, 🟠 medium, 🟢 low, and 🔴 unknown (a folder that could not be checked counts as red).

Changing the level while you work

/command-guard reports the current level; /command-guard off|low|medium|high changes it, takes effect on the next command and is remembered across sessions. An unknown word changes nothing.

  • off: never ask (every Bash command runs, nothing is classified or measured)
  • low: ask for everything, even low risk
  • medium: ask from medium risk up (the default)
  • high: ask only for high risk (unknown always asks)

The askFrom setting stays the default; a level you choose with the command overrides it. The choice is mirrored into the state value { plugin: 'command-guard', key: 'level' } (a string, off, low, medium or high), which other mods, such as the moddeck side bar, read with $.state.get.

Limits

  • Read-only probes (git, find, du; no shell, no network), each with a timeout; a folder scan classifies at most 3,000 files ("sampled" is said) and stops at the first timeout. If a probe fails the command is still held and a Not checked row says why.
  • The folder rows are a name-based guess: it cannot know about Time Machine or other backups, what a sync service or a remote still holds, or whether a "build" folder really is disposable. "Pushed" is as of your last fetch. A mount point is only noticed when it sits inside the folder.
  • Not seen: make clean, scripts (Rscript x.R), rsync --delete, ssh host rm, SQL files, aliases. Relative paths use the session directory; an earlier cd may be missed.
  • Native Windows: nothing is measured (the probes are Unix find/du); a risky command is held with a note to check the path yourself, and there are no folder rows. Nothing has been run on Windows.
  • Not verified live: how the question dialog wraps a long listing; macOS find/du were not run (only GNU ones, bun tests/real-folder.integration.ts).
Source 10 files
hooks/register.tsx 160 lines
1// command-guard: hold risky Bash commands, show what they would destroy, ask first.
2//
3// THIS IS A SAFETY NET, NOT A PERMISSION SYSTEM. It only ever adds a question; it never
4// allows anything the normal permission prompt would not. Keep your permissions on.
5//
6// HOW THE CALL IS HELD OPEN (the one engine detail that matters here):
7//   A hook has a 10 s budget of its OWN time, but the clock stops while a `next` call or
8//   any `$` call is in flight (HookBudget in the API types). `$.ui.ask` is a `$` call
9//   that waits for a person, so it can wait minutes at no cost. A pane with Proceed /
10//   Cancel buttons would NOT work: its press arrives as a separate event, and a hook
11//   waiting on a bare promise for that press burns its own budget and is timed out
12//   after 10 s. So the question goes through the engine's own question dialog; the first
13//   option is Cancel, which is the one the dialog highlights, so Enter means no.
14//   There is no timeout of our own: the dialog stays until answered; an interrupt
15//   (Esc, or the turn being stopped) rejects the ask and that is treated as Cancel.
16import { atom, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import { classify } from './classify'
20import { LEVELS, LEVEL_MEANING, asLevel, failClosedReason, isBelowBar, readConfig } from './config'
21import type { Level } from './config'
22import { denyText, dialogText, toastText } from './format'
23import { measureAll } from './measure'
24import type { Run } from './measure'
25import { looksWindows, unmeasured } from './windows'
26
27const PROBE_TIMEOUT_MS = 20_000
28const COMMAND = 'command-guard'
29
30/** Mirror of the persisted runtime level (`$.store` 'level'), so other mods can read it with $.state.get. */
31const levelAtom = atom({ plugin: 'command-guard', key: 'level' } as const, 'medium')
32
33/** The level in force: the person's runtime choice if there is one, else the `askFrom` setting. */
34async function levelNow($: EngineInterface, fallback: Level): Promise<Level> {
35  return asLevel(await safe(() => $.store.get('level'))) ?? fallback
36}
37
38const describe = (l: Level): string => `${l} (${LEVEL_MEANING[l]})`
39
40async function runCommand($: EngineInterface, args: string, fallback: Level): Promise<string> {
41  const word = args.trim().toLowerCase()
42  const current = await levelNow($, fallback)
43  if (word === '') {
44    return [
45      `Blast radius · ${describe(current)}`,
46      `Levels: ${LEVELS.map(l => `${l} = ${LEVEL_MEANING[l]}`).join(' · ')} · /command-guard <level> to change`,
47    ].join('\n')
48  }
49  const next = asLevel(word)
50  if (next === undefined) return `Blast radius · "${args.trim().slice(0, 30)}" is not a level, nothing changed · use off, low, medium or high (now ${current})`
51  try {
52    await $.store.set('level', next)
53    await update($, levelAtom, () => next)
54  } catch {
55    return 'Blast radius · that did not work, nothing changed'
56  }
57  return `Blast radius · ${describe(next)}`
58}
59
60export const register: Register = (on, options) => {
61  const cfg = readConfig(options)
62
63  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64    // The person's runtime level overrides the setting; off is fail-open, before anything is classified.
65    const level = await levelNow($, cfg.askFrom)
66    if (level === 'off') return next(e)
67    const cwd = await safe(() => $.session.cwd())
68    const home = await safe(() => $.env.get('HOME'))
69    const found = classify(e.command, { cwd, home, extraPatterns: cfg.extraPatterns })
70    if (found.length === 0) return next(e)
71
72    const run: Run = (argv, dir, opts) => $.process.run(argv, { cwd: dir, timeoutMs: opts?.timeoutMs ?? PROBE_TIMEOUT_MS })
73    // WINDOWS: the probes below are Unix `find` / `du`. On native Windows `find` is a different program (text search), so an
74    // empty answer would look like "nothing there" and a destructive command would pass unasked. Never do that: hold
75    // the command with an honest "cannot measure here" instead. (Conditional findings such as `> file` need a file check
76    // we cannot make there, so they pass through rather than asking about every redirect.)
77    const win = await isWindows($, cwd, home)
78    const { reports, unlisted } = win ? unmeasured(found) : await measureAll(run, found, { maxListed: cfg.maxListed, home, cwd })
79    if (reports.length === 0 && unlisted === 0) return next(e) // conditions not met, e.g. `> new-file`
80    // Below the `askFrom` bar nothing is shown: a command whose every part is lower risk goes through. A part that
81    // was not checked (unlisted) or whose level is unknown always asks.
82    if (isBelowBar(reports.map(r => r.risk?.level ?? 'unknown'), unlisted, level)) return next(e)
83
84    const verdict = await confirm($, dialogText(e.command, reports, unlisted, cwd, cfg.maxListed))
85    if (verdict.isYes) {
86      await safe(() => $.ui.status(undefined))
87      return next(e)
88    }
89    const why = verdict.why
90
91    const toast = toastText(reports)
92    await safe(() => $.ui.toast(toast, { timeoutMs: 8000 }))
93    await safe(() => $.ui.status(toast))
94    await safe(() => $.clock.after(30_000, () => void safe(() => $.ui.status(undefined))))
95    return { deny: denyText(why, e.command, reports) }
96  }).catch(($, e, next) => {
97    // FAIL CLOSED, but only where the text itself looks destructive. The classifier or the
98    // measurer (or the dialog code) threw, or the hook outran its budget: if the command
99    // matches the coarse FALLBACK_RISKY regex or one of the user's own patterns we refuse
100    // rather than guess; anything else goes through, so a bug here cannot wedge ordinary
101    // work. Where `next` was already called (the user pressed Proceed) we just replay it.
102    if (next.called) return next(e)
103    const deny = failClosedReason((e as { command?: unknown }).command, cfg)
104    return deny === undefined ? next(e) : { deny }
105  })
106
107  on('session.start', async ($, e, next) => {
108    const level = await levelNow($, cfg.askFrom)
109    await safe(() => update($, levelAtom, () => level)) // the mirror starts from what is stored, else the setting
110    await safe(() =>
111      $.command.register({
112        name: COMMAND,
113        description: 'Change how much command-guard asks about: /command-guard [off|low|medium|high]',
114        argumentHint: '[off|low|medium|high]',
115      }),
116    ) // fail-open: without the command the setting still works
117    if (cfg.badPatterns.length)
118      await safe(() =>
119        $.ui.toast(`command-guard: ignored invalid extra patterns: ${cfg.badPatterns.map(p => p.replace(/\s+/g, ' ')).join(', ')}`, { timeoutMs: 10_000 }),
120      )
121    return next(e)
122  })
123
124  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, e.args, cfg.askFrom) })).catch(() => ({
125    text: 'Blast radius · that did not work, nothing changed',
126  }))
127}
128
129async function isWindows($: EngineInterface, cwd: string | undefined, home: string | undefined): Promise<boolean> {
130  if (looksWindows(cwd) || looksWindows(home)) return true
131  return (await safe(() => $.env.get('OS'))) === 'Windows_NT'
132}
133
134type Verdict = { isYes: true } | { isYes: false; why: string }
135
136// Only an exact "Proceed" lets the command run; Cancel, free text under Other, Esc, a
137// rejected dialog and a run with no surface at all are all a no.
138async function confirm($: EngineInterface, text: string): Promise<Verdict> {
139  const surfaces = await safe(() => $.session.surfaces())
140  if (surfaces !== undefined && surfaces.length === 0) return { isYes: false, why: 'no screen to ask on in this run' }
141  try {
142    const answer = await $.ui.ask(text, {
143      options: ['Cancel', 'Proceed'], // Cancel first: the dialog opens on it
144      header: 'Blast radius',
145    })
146    if (answer === 'Proceed') return { isYes: true }
147    return { isYes: false, why: answer === 'Cancel' ? 'cancelled in the dialog' : `answered "${answer.slice(0, 40).replace(/\s+/g, ' ')}", not Proceed` }
148  } catch {
149    return { isYes: false, why: 'no answer: dialog dismissed, or no one to ask' }
150  }
151}
152
153async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
154  try {
155    return await f()
156  } catch {
157    return undefined
158  }
159}
160
hooks/classify.ts 511 lines
1// The classifier: a PURE function from a shell command line to the list of risky
2// simple commands in it. No `$`, no file access: whether a target really exists is
3// the measurer's business. Anything it cannot see through (a variable as command
4// name, nesting deeper than DEPTH_LIMIT) is reported rather than waved through.
5import { lex } from './lex'
6import type { Tok, Word } from './lex'
7
8export type Kind =
9  | 'rm' | 'find' | 'truncate' | 'shred' | 'dd' | 'mkfs' | 'redirect' | 'overwrite' | 'chmod'
10  | 'git-clean' | 'git-reset' | 'git-discard' | 'git-push' | 'git-branch' | 'git-stash'
11  | 'sql' | 'r' | 'cargo-clean' | 'custom' | 'opaque'
12
13/** A path a command acts on. `path` is absolute and normalised; absent when `unresolved` says why. */
14export type Target = { raw: string; path?: string; glob: boolean; unresolved?: string }
15
16export type Finding = {
17  kind: Kind
18  reason: string // one line, shown under the command
19  command: string // the simple command as the shell would see it
20  cwd?: string // directory it runs in, undefined when a `cd` could not be followed
21  args: string[] // words after the command name (after the git subcommand for git)
22  targets: Target[]
23  critical?: boolean // targets / or the home directory
24  viaXargs?: boolean // the real operands arrive on stdin
25  // Held only if the measurer finds the condition true (the classifier cannot stat files).
26  conditional?: 'non-empty-file' | 'dest-exists'
27}
28
29export type ClassifyContext = {
30  cwd?: string // the session's working directory
31  home?: string // for `~`
32  extraPatterns?: readonly RegExp[] // the user's own, tested against the whole command line
33}
34
35type State = { cwd: string | undefined }
36type Ctx = ClassifyContext & { out: Finding[] }
37
38const DEPTH_LIMIT = 6
39
40export function classify(command: string, ctx: ClassifyContext = {}): Finding[] {
41  const out: Finding[] = []
42  scan(command, { cwd: ctx.cwd }, { ...ctx, out }, 0)
43  for (const re of ctx.extraPatterns ?? [])
44    if (re.test(command))
45      out.push(finding('custom', `matches your extra pattern ${re}`, [], [], undefined, command))
46  return out
47}
48
49// ---- paths ---------------------------------------------------------------------
50
51export function normalize(path: string): string {
52  const parts: string[] = []
53  for (const p of path.split('/'))
54    if (p === '..') parts.pop()
55    else if (p !== '' && p !== '.') parts.push(p)
56  return '/' + parts.join('/')
57}
58
59function target(w: Word, st: State, ctx: ClassifyContext): Target {
60  const base = { raw: w.text, glob: w.glob }
61  if (w.dynamic) return { ...base, unresolved: 'contains a variable or command substitution' }
62  if (w.text === '') return { ...base, unresolved: 'empty path' }
63  let t = w.text
64  if (w.tilde) {
65    if (t !== '~' && !t.startsWith('~/')) return { ...base, unresolved: 'another user\'s home' }
66    if (!ctx.home) return { ...base, unresolved: 'home directory unknown' }
67    t = ctx.home + t.slice(1)
68  }
69  if (!t.startsWith('/')) {
70    if (!st.cwd) return { ...base, unresolved: 'relative path after a cd that could not be followed' }
71    t = `${st.cwd}/${t}`
72  }
73  return { ...base, path: normalize(t) }
74}
75
76const isCritical = (t: Target, home?: string) =>
77  /^(\/|~\/?|\$HOME\/?|\.|\.\/|\.\.|\.\.\/)\*?$/.test(t.raw) ||
78  t.path === '/' || (home !== undefined && t.path === home) || t.raw === '/*'
79
80// ---- argument helpers --------------------------------------------------------------
81
82type Flags = { short: Set<string>; long: Set<string>; operands: Word[] }
83
84// Options may appear anywhere before `--` (GNU permutes). `valued` lists short options
85// that take a separate value word, `valuedLong` the long ones; their values are not operands.
86function flags(args: Word[], valued = '', valuedLong: string[] = []): Flags {
87  const f: Flags = { short: new Set(), long: new Set(), operands: [] }
88  let ended = false
89  for (let k = 0; k < args.length; k++) {
90    const t = args[k]!.text
91    if (ended || !t.startsWith('-') || t === '-') f.operands.push(args[k]!)
92    else if (t === '--') ended = true
93    else if (t.startsWith('--')) {
94      const name = t.slice(2).split('=')[0]!
95      f.long.add(name)
96      if (!t.includes('=') && valuedLong.includes(name)) k++
97    } else {
98      for (let c = 1; c < t.length; c++) {
99        f.short.add(t[c]!)
100        if (valued.includes(t[c]!)) {
101          if (c === t.length - 1) k++
102          break
103        }
104      }
105    }
106  }
107  return f
108}
109
110const base = (s: string) => s.slice(s.lastIndexOf('/') + 1)
111const isAssignment = (w: Word) => /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(w.text)
112const show = (w: Word) => (/[\s'"$;|&<>()]/.test(w.text) || w.text === '' ? JSON.stringify(w.text) : w.text)
113
114function finding(
115  kind: Kind, reason: string, words: Word[], targets: Target[], cwd: string | undefined,
116  commandText?: string, extra: Partial<Finding> = {},
117): Finding {
118  return {
119    kind, reason, command: commandText ?? words.map(show).join(' '), cwd,
120    args: [], targets, ...extra,
121  }
122}
123
124// ---- wrappers: sudo, env, time, xargs ... -------------------------------------------
125
126// `valued`: short options of the wrapper that take a value; `positional`: operands to skip.
127const WRAPPERS: Record<string, { valued: string; positional?: number }> = {
128  sudo: { valued: 'ughpCDrtTUR' }, doas: { valued: 'uC' }, env: { valued: 'uCS' },
129  time: { valued: 'fo' }, nice: { valued: 'n' }, ionice: { valued: 'cnpt' }, nohup: { valued: '' },
130  command: { valued: '' }, builtin: { valued: '' }, exec: { valued: 'a' }, stdbuf: { valued: 'ioe' },
131  setsid: { valued: '' }, caffeinate: { valued: 'tw' }, chronic: { valued: '' }, unbuffer: { valued: '' },
132  timeout: { valued: 'sk', positional: 1 }, gtimeout: { valued: 'sk', positional: 1 },
133  xargs: { valued: 'INPdaELsJRS' },
134}
135// Words that start a command in compound statements: `if x; then rm -rf y; fi`.
136const RESERVED = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', 'function'])
137
138function skipWrapperOptions(words: Word[], from: number, spec: { valued: string; positional?: number }): number {
139  let i = from
140  while (i < words.length) {
141    const t = words[i]!.text
142    if (t === '--') return i + 1 + (spec.positional ?? 0)
143    if (isAssignment(words[i]!)) i++
144    else if (t.startsWith('-') && t.length > 1) {
145      if (!t.startsWith('--'))
146        for (let c = 1; c < t.length; c++)
147          if (spec.valued.includes(t[c]!)) {
148            if (c === t.length - 1) i++
149            break
150          }
151      i++
152    } else break
153  }
154  return i + (spec.positional ?? 0)
155}
156
157type Unwrapped = { name: string; args: Word[]; viaXargs: boolean; dynamicName: boolean; words: Word[] }
158
159function unwrap(all: Word[]): Unwrapped | undefined {
160  let i = 0
161  let viaXargs = false
162  while (i < all.length) {
163    const w = all[i]!
164    if (isAssignment(w) || (!w.dynamic && RESERVED.has(w.text))) i++
165    else if (w.dynamic)
166      return { name: '', args: all.slice(i + 1), viaXargs, dynamicName: true, words: all.slice(i) }
167    else {
168      const name = base(w.text)
169      const spec = WRAPPERS[name]
170      if (!spec) return { name, args: all.slice(i + 1), viaXargs, dynamicName: false, words: all.slice(i) }
171      if (name === 'xargs') viaXargs = true
172      i = skipWrapperOptions(all, i + 1, spec)
173    }
174  }
175  return undefined
176}
177
178// ---- the walk: lists, pipelines, subshells, cd -----------------------------------------
179
180type Simple = {
181  words: Word[]
182  redirs: { op: string; fd?: string; target?: Word }[]
183  heredocs: string[]
184}
185const emptyCmd = (): Simple => ({ words: [], redirs: [], heredocs: [] })
186
187function scan(src: string, st: State, ctx: Ctx, depth: number): void {
188  if (depth > DEPTH_LIMIT) {
189    ctx.out.push(finding('opaque', 'nested too deeply to analyse', [], [], st.cwd, src.slice(0, 80)))
190    return
191  }
192  walk(lex(src), st, ctx, depth)
193}
194
195function walk(toks: Tok[], st: State, ctx: Ctx, depth: number): void {
196  let pipeline: Simple[] = []
197  let cmd = emptyCmd()
198  const endCmd = () => {
199    if (cmd.words.length || cmd.redirs.length) pipeline.push(cmd)
200    cmd = emptyCmd()
201  }
202  const endPipeline = () => {
203    endCmd()
204    if (pipeline.length) analyzePipeline(pipeline, st, ctx, depth)
205    pipeline = []
206  }
207  for (let k = 0; k < toks.length; k++) {
208    const tk = toks[k]!
209    if (tk.t === 'word') cmd.words.push(tk.w)
210    else if (tk.t === 'redir') {
211      const next = toks[k + 1]
212      const t = next?.t === 'word' ? next.w : undefined
213      if (t) k++
214      cmd.redirs.push({ op: tk.op, ...(tk.fd ? { fd: tk.fd } : {}), ...(t ? { target: t } : {}) })
215      if (tk.body !== undefined) cmd.heredocs.push(tk.body)
216      if (tk.op === '<<<' && t) cmd.heredocs.push(t.text)
217    } else if (tk.op === '(') {
218      const close = matchParen(toks, k)
219      if (cmd.words.length === 0) {
220        // A subshell: a `cd` inside it does not leak out.
221        walk(toks.slice(k + 1, close), { ...st }, ctx, depth)
222      }
223      else endPipeline() // a function definition `f() { ... }`: its body is walked as ordinary commands
224      k = close
225    } else if (tk.op === ')') endPipeline() // `case x in a) ...`
226    else if (tk.op === '|') endCmd()
227    else endPipeline()
228  }
229  endPipeline()
230}
231
232function matchParen(toks: Tok[], open: number): number {
233  let depth = 0
234  for (let k = open; k < toks.length; k++) {
235    const t = toks[k]!
236    if (t.t === 'op' && t.op === '(') depth++
237    else if (t.t === 'op' && t.op === ')' && --depth === 0) return k
238  }
239  return toks.length
240}
241
242const SQL_CLIENT = /^(psql|sqlite3?|mysql|mariadb|duckdb)$/
243
244function analyzePipeline(cmds: Simple[], st: State, ctx: Ctx, depth: number): void {
245  const single = cmds.length === 1
246  // SQL fed through a pipe or heredoc belongs to the client at the end of the pipeline.
247  const sqlText = cmds.some(c => SQL_CLIENT.test(unwrap(c.words)?.name ?? ''))
248    ? cmds.flatMap(c => [...c.words.filter(w => !w.text.startsWith('--')).map(w => w.text), ...c.heredocs]).join(' ')
249    : undefined
250  for (const c of cmds) analyzeCommand(c, single ? st : { ...st }, ctx, depth, sqlText)
251}
252
253function analyzeCommand(c: Simple, st: State, ctx: Ctx, depth: number, sqlText?: string): void {
254  for (const w of [...c.words, ...c.redirs.flatMap(r => (r.target ? [r.target] : []))])
255    for (const sub of w.subs) scan(sub, { ...st }, ctx, depth + 1)
256  clobbers(c, st, ctx)
257  const u = unwrap(c.words)
258  if (!u) return
259  const text = u.words.map(show).join(' ')
260  if (u.dynamicName) {
261    const hot = u.args.some(a => /^-[a-zA-Z]*(rf|fr|Rf|fR)|^--recursive$/.test(a.text))
262    if (hot)
263      ctx.out.push(finding('opaque', 'the command name is a variable and the flags look like rm -rf', u.words, [], st.cwd, text))
264    return
265  }
266  const here = { ...ctx, st, u, text, sqlText, depth }
267  if (u.name === 'cd' || u.name === 'pushd') return cd(u.args, st, ctx)
268  if (u.name === 'popd') return void (st.cwd = undefined)
269  if (/^(ba|z|da|k|a)?sh$/.test(u.name)) return shellC(here)
270  if (u.name === 'eval') return scan(u.args.map(a => a.text).join(' '), { ...st }, ctx, depth + 1)
271  const hit = rules(here)
272  if (hit) ctx.out.push(...hit)
273}
274
275function cd(args: Word[], st: State, ctx: ClassifyContext): void {
276  const operands = flags(args).operands
277  if (operands.length === 0) {
278    st.cwd = ctx.home
279    return
280  }
281  const t = target(operands[0]!, st, ctx)
282  st.cwd = operands[0]!.text === '-' || t.glob ? undefined : t.path
283}
284
285type Here = Ctx & { st: State; u: Unwrapped; text: string; sqlText: string | undefined; depth: number }
286
287// bash -c 'script', sh -lc "script"
288function shellC(h: Here): void {
289  const a = h.u.args
290  const k = a.findIndex(w => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(w.text))
291  const script = k >= 0 ? a[k + 1] : undefined
292  if (script) scan(script.text, { ...h.st }, h, h.depth + 1)
293}
294
295// `> file` and `&> file` truncate; `>>` appends. Whether the file exists is for the measurer.
296function clobbers(c: Simple, st: State, ctx: Ctx): void {
297  for (const r of c.redirs) {
298    if (!['>', '>|', '&>'].includes(r.op) || !r.target) continue
299    if (r.target.text.startsWith('/dev/')) continue
300    const t = target(r.target, st, ctx)
301    if (t.unresolved) continue // unknowable target: not held (documented limit)
302    const text = [...c.words.map(show), r.op, show(r.target)].join(' ')
303    ctx.out.push(finding('redirect', `${r.op} overwrites ${t.raw} if it already has content`, [], [t], st.cwd, text, {
304      conditional: 'non-empty-file',
305    }))
306  }
307}
308
309// ---- the per-command rules ------------------------------------------------------------
310
311function rules(h: Here): Finding[] | undefined {
312  const { name, args } = h.u
313  const one = (f: Finding | undefined) => (f ? [f] : undefined)
314  const targets = (ws: Word[]) => ws.map(w => target(w, h.st, h))
315  const mk = (kind: Kind, reason: string, ts: Word[], extra: Partial<Finding> = {}) => {
316    const tt = targets(ts)
317    return finding(kind, reason, h.u.words, tt, h.st.cwd, h.text, {
318      args: args.map(a => a.text), critical: tt.some(t => isCritical(t, h.home)) || undefined,
319      viaXargs: h.u.viaXargs || undefined, ...extra,
320    })
321  }
322
323  switch (true) {
324    case name === 'rm': {
325      const f = flags(args)
326      const recursive = f.short.has('r') || f.short.has('R') || f.long.has('recursive')
327      const force = f.short.has('f') || f.long.has('force')
328      const bulk = h.u.viaXargs || f.operands.some(o => o.glob)
329      if (!recursive && !force && !bulk) return undefined // plain `rm file` is not held
330      const why = recursive ? 'recursive delete (rm -r): not recoverable' : force ? 'forced delete (rm -f): not recoverable' : 'bulk delete by glob or xargs: not recoverable'
331      return one(mk('rm', why, f.operands))
332    }
333    case name === 'find': return one(findRule(h, mk))
334    case name === 'git': return gitRule(h, mk)
335    case name === 'truncate': {
336      const f = flags(args, 'sr', ['size', 'reference'])
337      return one(mk('truncate', 'truncate cuts file contents', f.operands))
338    }
339    case name === 'shred': return one(mk('shred', 'shred overwrites file contents irrecoverably', flags(args, 'ns', ['iterations', 'size', 'random-source']).operands))
340    case name === 'dd': {
341      const of = args.find(a => a.text.startsWith('of='))
342      if (!of) return undefined
343      const path = of.text.slice(3)
344      if (/^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/.test(path)) return undefined
345      return one(mk('dd', `dd writes over ${path}`, [{ ...of, text: path }]))
346    }
347    case /^(mkfs(\..+)?|mke2fs|newfs(_.+)?|mkswap|wipefs)$/.test(name):
348      return one(mk('mkfs', `${name} destroys everything on the target device`, flags(args).operands.slice(-1)))
349    case name === 'diskutil' && /^(erase|reformat|partitionDisk|zeroDisk|secureErase)/i.test(args[0]?.text ?? ''):
350      return one(mk('mkfs', `diskutil ${args[0]!.text} destroys data on the target disk`, []))
351    case /^(chmod|chown|chgrp)$/.test(name): {
352      const f = flags(args)
353      if (!f.long.has('recursive') && !f.short.has('R')) return undefined
354      return one(mk('chmod', `${name} -R rewrites permissions or ownership of a whole tree`, f.long.has('reference') ? f.operands : f.operands.slice(1)))
355    }
356    case (name === 'mv' || name === 'cp'): {
357      const f = flags(args, 't', ['target-directory'])
358      if (!(f.short.has('f') || f.long.has('force')) || f.short.has('n') || f.long.has('no-clobber')) return undefined
359      if (f.operands.length < 2) return undefined
360      const dest = f.operands[f.operands.length - 1]!
361      const srcs = f.operands.slice(0, -1)
362      return one(mk('overwrite', `${name} -f replaces existing files without asking`, [dest], {
363        conditional: 'dest-exists', args: srcs.map(s => s.text),
364      }))
365    }
366    case name === 'cargo': {
367      const sub = args.find(a => !a.text.startsWith('-') && !a.text.startsWith('+'))
368      if (sub?.text !== 'clean') return undefined
369      return one(mk('cargo-clean', 'cargo clean deletes the target/ directory (a full rebuild follows)', []))
370    }
371    case SQL_CLIENT.test(name): return one(sqlRule(h, mk))
372    case name === 'R' || name === 'Rscript': return rRule(h, mk)
373    default: return undefined
374  }
375}
376
377type Mk = (kind: Kind, reason: string, ts: Word[], extra?: Partial<Finding>) => Finding
378
379// find ... -delete / -exec rm
380function findRule(h: Here, mk: Mk): Finding | undefined {
381  const t = h.u.args.map(a => a.text)
382  const del = t.includes('-delete')
383  const exec = t.findIndex((x, k) => /^-(exec|execdir|ok|okdir)$/.test(x) && isRemover(t.slice(k + 1)))
384  if (!del && exec < 0) return undefined
385  return mk('find', del ? 'find -delete removes every match' : 'find -exec rm removes every match', [])
386}
387
388const isRemover = (rest: string[]) => {
389  const c = base(rest[0] ?? '')
390  return /^(rm|unlink|shred|rmdir)$/.test(c) || (/^(ba|z)?sh$/.test(c) && /\brm\b/.test(rest.slice(1).join(' ')))
391}
392
393const BROAD = /^(\.|\.\/|\*|:\/|:\/\.|:\/\*|:\(top\))$/
394
395function gitRule(h: Here, mk: Mk): Finding[] | undefined {
396  const a = h.u.args
397  let i = 0
398  let cwd = h.st.cwd
399  while (i < a.length && a[i]!.text.startsWith('-')) {
400    const t = a[i]!.text
401    if (t === '-C' && a[i + 1]) {
402      const r = target(a[i + 1]!, { cwd }, h)
403      cwd = r.path
404      i += 2
405    } else i += ['-c', '--git-dir', '--work-tree', '--namespace'].includes(t) ? 2 : 1
406  }
407  const sub = a[i]?.text
408  const rest = a.slice(i + 1)
409  const f = flags(rest)
410  const texts = rest.map(r => r.text)
411  const make = (kind: Kind, reason: string, ts: Word[] = []) => {
412    const m = mk(kind, reason, ts, { args: texts })
413    return { ...m, cwd }
414  }
415  const cluster = (c: string) => f.short.has(c)
416
417  switch (sub) {
418    case 'clean':
419      if (f.long.has('dry-run') || cluster('n') || cluster('i') || f.long.has('interactive')) return undefined
420      return cluster('f') || f.long.has('force') ? [make('git-clean', 'git clean -f deletes untracked files: not recoverable')] : undefined
421    case 'reset':
422      return f.long.has('hard') ? [make('git-reset', 'git reset --hard discards uncommitted changes and moves the branch')] : undefined
423    case 'checkout': {
424      const afterDashes = texts.includes('--') ? texts.slice(texts.indexOf('--') + 1) : []
425      const broad = afterDashes.some(x => BROAD.test(x)) || (afterDashes.length === 0 && f.operands.length === 1 && BROAD.test(f.operands[0]!.text))
426      if (broad || cluster('f') || f.long.has('force'))
427        return [make('git-discard', 'git checkout discards uncommitted changes to tracked files')]
428      return undefined
429    }
430    case 'restore': {
431      const staged = f.long.has('staged') || cluster('S')
432      const worktree = !staged || cluster('W') || f.long.has('worktree')
433      return worktree && f.operands.some(o => BROAD.test(o.text))
434        ? [make('git-discard', 'git restore discards uncommitted changes to tracked files')] : undefined
435    }
436    case 'push': return pushRule(f, make)
437    case 'branch': {
438      const del = cluster('D') || ((cluster('d') || f.long.has('delete')) && (cluster('f') || f.long.has('force')))
439      return del ? [make('git-branch', 'git branch -D deletes a branch even if unmerged')] : undefined
440    }
441    case 'stash': {
442      const op = f.operands[0]?.text
443      return op === 'drop' || op === 'clear' ? [make('git-stash', `git stash ${op} deletes stashed work`)] : undefined
444    }
445    default: return undefined
446  }
447}
448
449function pushRule(f: Flags, make: (k: Kind, r: string, t?: Word[]) => Finding): Finding[] | undefined {
450  const refspecs = f.operands.slice(1).map(o => o.text)
451  const force = f.long.has('force') || f.long.has('force-with-lease') || f.short.has('f') || refspecs.some(r => r.startsWith('+'))
452  const del = f.long.has('delete') || f.short.has('d') || refspecs.some(r => r.startsWith(':')) || f.long.has('mirror')
453  if (!force && !del) return undefined
454  return [make('git-push', del && !force ? 'git push --delete removes remote branches or tags' : 'a forced git push can overwrite commits on the remote')]
455}
456
457function sqlRule(h: Here, mk: Mk): Finding | undefined {
458  const text = h.sqlText ?? ''
459  const bad = destructiveSql(text)
460  return bad.length ? mk('sql', `destructive SQL: ${bad.join('; ')}`, [], { args: bad }) : undefined
461}
462
463/** Statements in `sql` that drop, truncate, or delete without WHERE. Exported for tests. */
464export function destructiveSql(sql: string): string[] {
465  const found: string[] = []
466  for (const stmt of sql.split(';')) {
467    const s = stmt.replace(/--.*$/gm, ' ').replace(/\s+/g, ' ').trim()
468    const m = /\b(DROP\s+\w+(\s+IF\s+EXISTS)?\s+\S+|TRUNCATE(\s+TABLE)?\s+\S+|DELETE\s+FROM\s+\S+)/i.exec(s)
469    if (!m) continue
470    if (/^DELETE\b/i.test(m[1]!) && /\bWHERE\b/i.test(s)) continue
471    found.push(m[1]!.slice(0, 60))
472  }
473  return found
474}
475
476// R -e / Rscript -e: unlink(recursive=TRUE), file.remove, fs::dir_delete, system("rm ...")
477function rRule(h: Here, mk: Mk): Finding[] | undefined {
478  const a = h.u.args
479  const code = a.flatMap((w, k) => (w.text === '-e' || w.text === '--expr' ? [a[k + 1]?.text ?? ''] : w.text.startsWith('--expr=') ? [w.text.slice(7)] : [])).join('\n')
480  if (!code) return undefined
481  const out: Finding[] = []
482  for (const m of code.matchAll(/\b(system2?|shell)\s*\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g))
483    scan(m[2]!.slice(1, -1), { ...h.st }, h, h.depth + 1)
484  // Only a literal first argument (or c("a", "b")) can be named; anything computed is "could not measure".
485  const calls = [...code.matchAll(/\b(?:unlink|file\.remove|dir_delete|file_delete)\s*\(\s*(?:c\(([^)]*)\)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))/g)]
486  const risky = /\bunlink\s*\(.*recursive\s*=\s*(TRUE|T)\b/s.test(code) || /\b(file\.remove|dir_delete|file_delete)\s*\(/.test(code)
487  if (!risky) return out.length ? out : undefined
488  const lits = calls.flatMap(m => [...(m[1] ?? m[2]!).matchAll(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g)].map(l => l[1] ?? l[2] ?? ''))
489  const words: Word[] = lits.map(text => ({ text, glob: /[*?]/.test(text), dynamic: false, tilde: text.startsWith('~'), subs: [] }))
490  out.push(mk('r', 'R code deletes files or directories (unlink / file.remove / dir_delete)', words, { args: [] }))
491  return out
492}
493
494// ---- last-resort regex ------------------------------------------------------------------
495
496/**
497 * Trivially safe fallback for when the classifier itself throws: coarse on purpose,
498 * it only has to say "this text looks destructive" so the hook can fail closed.
499 */
500export const FALLBACK_RISKY = new RegExp(
501  [
502    String.raw`\brm\s+(-\w*[rRf]|--(recursive|force))`,
503    String.raw`\bfind\b.*(-delete|-exec\s+\S*rm\b)`,
504    String.raw`\bgit\b.*\b(clean\s+-\w*f|reset\s+--hard|push\b.*(--force|--delete|\s-\w*f|\s\+)|branch\s+-D|stash\s+(drop|clear)|checkout\s+(--\s+)?\.|restore\s+\.)`,
505    String.raw`\b(truncate|shred|mkfs\S*|newfs\S*)\b`, String.raw`\bdd\b.*\bof=`,
506    String.raw`\b(chmod|chown|chgrp)\s+-\w*R`, String.raw`\bcargo\s+clean\b`,
507    String.raw`\b(DROP\s+\w+|TRUNCATE|DELETE\s+FROM)\b`, String.raw`\bunlink\s*\(`,
508    String.raw`(^|[^>&0-9])>\s*(?!\/dev\/)[^&>\s]`,
509  ].join('|'), 'i',
510)
511
hooks/config.ts 62 lines
1import type { PluginOptions } from 'claude-code'
2
3import { FALLBACK_RISKY } from './classify'
4
5export type AskFrom = 'low' | 'medium' | 'high'
6/** The runtime choice: the three `askFrom` bars plus `off` (never ask). */
7export type Level = AskFrom | 'off'
8export const LEVELS: readonly Level[] = ['off', 'low', 'medium', 'high']
9
10/** One line per level, for `/command-guard` with no argument. */
11export const LEVEL_MEANING: Record<Level, string> = {
12  off: 'never ask',
13  low: 'ask for everything, even low risk',
14  medium: 'ask from medium risk up',
15  high: 'ask only for high risk',
16}
17
18/** A stored or typed word as a level, or undefined when it is none. */
19export const asLevel = (v: unknown): Level | undefined => {
20  const t = typeof v === 'string' ? v.trim().toLowerCase() : ''
21  return (LEVELS as readonly string[]).includes(t) ? (t as Level) : undefined
22}
23export type BlastConfig = { maxListed: number; extraPatterns: RegExp[]; badPatterns: string[]; askFrom: AskFrom }
24
25/** Rank of a risk level for the `askFrom` bar. Unknown (not checked) ranks with high: it is never skipped. */
26export const RANK = { low: 0, medium: 1, high: 2, unknown: 2 } as const
27
28/** True when nothing needs a dialog: every part is below the `askFrom` bar and none went unchecked (`unlisted`). */
29export const isBelowBar = (levels: ('low' | 'medium' | 'high' | 'unknown')[], unlisted: number, askFrom: AskFrom): boolean =>
30  unlisted === 0 && levels.every(l => RANK[l] < RANK[askFrom])
31
32/** Options from plugin.json userConfig. A pattern that is not a valid regex is reported, never silently dropped. */
33export function readConfig(options: PluginOptions): BlastConfig {
34  const n = Number(options.maxListed)
35  const maxListed = Number.isFinite(n) ? Math.min(200, Math.max(5, Math.round(n))) : 5
36  const raw = Array.isArray(options.extraPatterns) ? (options.extraPatterns as string[]) : []
37  const extraPatterns: RegExp[] = []
38  const badPatterns: string[] = []
39  for (const p of raw) {
40    try {
41      extraPatterns.push(new RegExp(p))
42    } catch {
43      badPatterns.push(p)
44    }
45  }
46  const askFrom: AskFrom = options.askFrom === 'low' || options.askFrom === 'high' ? options.askFrom : 'medium'
47  return { maxListed, extraPatterns, badPatterns, askFrom }
48}
49
50/**
51 * The fail-closed rule, as a pure function: when the classifier, the measurer or the dialog
52 * code throws, refuse only a command whose text looks destructive to the coarse fallback
53 * regex (or to one of the user's own patterns). Returns the refusal, or undefined to let it run.
54 */
55export function failClosedReason(command: unknown, cfg: BlastConfig): string | undefined {
56  const text = typeof command === 'string' ? command : ''
57  const risky = FALLBACK_RISKY.test(text) || cfg.extraPatterns.some(re => re.test(text))
58  return risky
59    ? `Blocked by command-guard: could not analyse this command and it looks destructive. Not run: ${text.replace(/\s+/g, ' ').trim().slice(0, 200)}. Ask the user to confirm, then retry.`
60    : undefined
61}
62
hooks/format.ts 140 lines
1// Text for the dialog, the refusal the model reads and the toast. Plain text only: the dialog is the
2// engine's own question dialog, which draws the same on every surface. Layout, top to bottom:
3//   a one-line headline ("High risk · deletes data/"), the command, a few "Label   value" rows
4//   (one honesty note at most), then the question. Nothing here contains ALL-CAPS words.
5import type { Finding } from './classify'
6import type { Row } from './folder'
7import type { Report } from './measure'
8import { PUSHED_NOTE } from './recover'
9import type { RiskLevel } from './recover'
10
11const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
12const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
13// first letter up, unless the text starts with a command name ("git diff failed")
14const cap1 = (s: string) => (/^(git|rm|cp|mv|chmod|chown|find|du|dd|cargo|psql|mysql|sqlite3)\b/.test(s) ? s : s.charAt(0).toUpperCase() + s.slice(1))
15
16const LABEL_WIDTH = 13
17const DEFAULT_ROWS = 5 // rows shown under a list heading; the rest is "+N more"
18const MAX_NOTES = 2
19const FOOTNOTE = 'Estimated from file names and your last fetch.'
20// The dialog takes plain text only, so the level is coloured with a dot. Unknown counts as red: it was not checked.
21const WORD: Record<RiskLevel, string> = { high: '🔴 High', medium: '🟠 Medium', low: '🟢 Low', unknown: '🔴 Unknown' }
22const WORST_FIRST: RiskLevel[] = ['high', 'unknown', 'medium', 'low']
23
24const row = (label: string, value: string) => `${label.padEnd(LABEL_WIDTH)}${value}`
25const levelOf = (r: Report): RiskLevel => r.risk?.level ?? 'unknown'
26const worst = (reports: Report[]): RiskLevel => WORST_FIRST.find(l => reports.some(r => levelOf(r) === l)) ?? 'unknown'
27
28// What the command does, when the measurer could not say.
29function fallbackAction(f: Finding): string {
30  const names = f.targets.map(t => t.raw)
31  const what = (none: string) => (names.length === 1 ? clip(names[0]!, 50) : names.length > 1 ? `${names.length} paths` : none)
32  switch (f.kind) {
33    case 'rm': return `deletes ${f.viaXargs ? 'the files it is given' : what('files')}`
34    case 'r': return 'deletes files from R code'
35    case 'truncate': return `empties ${what('files')}`
36    case 'shred': return `shreds ${what('files')}`
37    case 'chmod': return `changes permissions of ${what('a tree')}`
38    case 'cargo-clean': return 'deletes target/'
39    case 'redirect': case 'overwrite': return `overwrites ${what('files')}`
40    case 'dd': return 'overwrites data'
41    case 'mkfs': return 'erases a disk'
42    case 'find': return 'deletes every match'
43    case 'git-clean': return 'deletes untracked files'
44    case 'git-reset': case 'git-discard': return 'discards uncommitted changes'
45    case 'git-push': return 'overwrites remote history'
46    case 'git-branch': return 'deletes a branch'
47    case 'git-stash': return 'deletes stashed work'
48    case 'sql': return 'runs destructive SQL'
49    case 'custom': return 'matches your extra pattern'
50    case 'opaque': return clip(f.reason, 70)
51  }
52}
53const actionOf = (r: Report) => r.action ?? fallbackAction(r.finding)
54
55function list(title: string, rows: string[], more: number, max: number): string[] {
56  if (rows.length === 0) return []
57  const shown = rows.slice(0, max)
58  const left = more + rows.length - shown.length
59  return [title, ...shown.map(x => `  ${clip(x, 110)}`), ...(left > 0 ? [`  +${left} more`] : [])]
60}
61
62function folderRows(rows: Row[], indent = ''): string[] {
63  return rows.map(([k, v]) => indent + row(k, clip(v, 200)))
64}
65
66function details(r: Report, sessionCwd: string | undefined, max: number): string[] {
67  const f = r.finding
68  const folders = r.folders ?? []
69  const out: string[] = []
70  if (f.cwd && f.cwd !== sessionCwd) out.push(row('Runs in', f.cwd))
71  if (folders.length === 1) out.push(...folderRows(folders[0]!.rows))
72  else
73    for (const fr of folders) out.push(fr.title, ...folderRows(fr.rows, '  '))
74  if (folders.length === 0 && r.risk?.headline) out.push(row('Impact', clip(cap1(r.risk.headline), 160)))
75  for (const g of r.riskGroups ?? []) if (!g.subset && !(g.extra && folders.length > 0)) out.push(...list(g.title, g.rows, g.more, max))
76  // a scanned folder already has its name in the headline and its size in the rows above
77  const scanned = folders.length === 1 && folders[0]!.rows.some(([k]) => k === 'Size')
78  r.groups.forEach((g, k) => {
79    if (k === 0 && scanned && g.rows.length <= 1 && g.more === 0) return
80    if (g.extra && scanned) return
81    out.push(...list(g.title, g.rows, g.more, max))
82  })
83  if (folders.length === 0 && r.summary.length && r.items !== 1) out.push(row('Total', r.summary.join('; ')))
84  const notes = r.notes.filter(n => !(folders.length > 0 && n === PUSHED_NOTE)).slice(0, MAX_NOTES)
85  for (const n of notes) out.push(row('Note', clip(cap1(n), 120)))
86  if (r.unmeasured && r.unmeasured !== 'native Windows') out.push(row('Not checked', clip(cap1(r.unmeasured), 120)))
87  return out
88}
89
90/** The question shown in the dialog. Ends in a question mark, as the dialog wants. */
91export function dialogText(command: string, reports: Report[], unlisted: number, sessionCwd?: string, maxRows = DEFAULT_ROWS): string {
92  const many = reports.length > 1
93  const lines: string[] = []
94  if (many) lines.push(`${WORD[worst(reports)]} risk · ${reports.length + unlisted} risky commands`, '')
95  else lines.push(`${WORD[worst(reports)]} risk · ${reports[0] ? clip(actionOf(reports[0]), 90) : 'risky command'}`, clip(oneLine(command), 200), '')
96  reports.forEach(r => {
97    if (many) lines.push(`${WORD[levelOf(r)]} · ${clip(actionOf(r), 80)}`, `  ${clip(oneLine(r.finding.command || r.finding.kind), 120)}`)
98    const d = details(r, sessionCwd, maxRows)
99    lines.push(...(many ? d.map(x => `  ${x}`) : d), '')
100  })
101  if (unlisted > 0) lines.push(`+${unlisted} more risky ${unlisted === 1 ? 'command' : 'commands'}, not checked`, '')
102  // the closing question is never clipped away: the dialog wants the text to end in a question mark
103  const footer = [...(reports.some(r => (r.folders ?? []).length > 0) ? [FOOTNOTE, ''] : []), 'Run this command?']
104  const body = lines.join('\n')
105  return clip(body, 6000 - footer.join('\n').length - 1) + '\n' + footer.join('\n')
106}
107
108/** One line per fact, short: what the model reads after a refusal. */
109export function summaryLines(reports: Report[], maxRows = 6): string[] {
110  const out: string[] = []
111  for (const r of reports) {
112    out.push(`${WORD[levelOf(r)]} risk · ${actionOf(r)}${r.finding.cwd ? ` (in ${r.finding.cwd})` : ''}`)
113    const folders = r.folders ?? []
114    if (folders.length === 0 && r.risk?.headline) out.push(`  Impact: ${clip(r.risk.headline, 220)}`)
115    for (const fr of folders) out.push(`  ${clip(fr.title, 120)} · Recoverable: ${clip(fr.rows.at(-1)?.[1] ?? '', 200)}`)
116    let left = maxRows
117    for (const g of r.groups)
118      for (const x of g.rows.slice(0, left)) (out.push(`  ${clip(x, 120)}`), left--)
119    if (r.items !== 1) out.push(...r.summary.map(s => `  Total: ${s}`))
120    if (r.unmeasured) out.push(`  Not checked: ${clip(r.unmeasured, 120)}`)
121  }
122  return out
123}
124
125export function denyText(why: string, command: string, reports: Report[]): string {
126  return clip(
127    [
128      `Blocked by command-guard (${why}). Not run: ${clip(oneLine(command), 200)}`,
129      ...summaryLines(reports),
130      'Ask the user before retrying, or use a narrower, reversible command.',
131    ].join('\n'), 2500,
132  )
133}
134
135/** One line (no line breaks): shown as a toast and in the status line. */
136export function toastText(reports: Report[]): string {
137  const total = reports.flatMap(r => r.summary)[0]?.replace(/^1 item, /, '')
138  return oneLine(`Blocked ${clip(oneLine(reports[0]?.finding.command ?? 'command'), 60)}${total ? ` · ${total}` : ''}`)
139}
140
hooks/measure.ts 580 lines
1// The measurer: READ-ONLY look at what a classified command would hit. Every probe
2// is an argv run through `run` (= $.process.run, no shell), so nothing the model
3// wrote is ever evaluated. The one place user text reaches an argv is a path or a ref,
4// and those are absolute paths or validated against REF before use.
5//
6// Contract: `measure` never throws and never says "fine". When a probe fails it sets
7// `unmeasured` and the caller holds the command anyway (fail closed on measuring).
8import type { Finding, Target } from './classify'
9import { normalize } from './classify'
10import { assessFolders } from './folder'
11import type { FolderRisk, Known, Mode } from './folder'
12import { assessLoss } from './recover'
13import type { Risk } from './recover'
14
15export type RunResult = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }
16export type Run = (argv: readonly string[], cwd?: string, opts?: { timeoutMs?: number }) => Promise<RunResult>
17// home and cwd are only for the folder-risk wording (CRITICAL: home, working directory, its parents); both optional
18export type Config = { maxListed: number; home?: string; cwd?: string }
19
20// subset: the rows only repeat names the plain listing shows; extra: left out of the dialog when a folder summary is shown
21export type Group = { title: string; rows: string[]; more: number; subset?: boolean; extra?: boolean }
22export type Report = {
23  finding: Finding
24  groups: Group[]
25  risk?: Risk // what could not be got back: uncommitted, untracked, unpushed, outside git
26  riskGroups?: Group[] // the rows behind `risk`, shown BEFORE the plain listing
27  folders?: FolderRisk[] // per directory: what it is, what is in it, what could be got back
28  summary: string[] // totals
29  action?: string // what the command does, lower case, for the headline: "deletes data/"
30  items?: number // how many existing targets a path command names
31  notes: string[] // caveats that do not stop the hold
32  unmeasured?: string // set when the blast radius could not be established
33  skip?: boolean // a conditional finding whose condition is false: nothing to hold
34}
35
36// ---- formatting and parsing (pure, exported for tests) --------------------------------
37
38export const fmtInt = (n: number) => n.toLocaleString('en-US')
39export function fmtBytes(bytes: number): string {
40  const u = ['B', 'KB', 'MB', 'GB', 'TB']
41  let v = bytes
42  let k = 0
43  while (v >= 1024 && k < u.length - 1) (v /= 1024, k++)
44  return `${k === 0 ? v : +v.toFixed(v >= 10 ? 0 : 1)} ${u[k]}`
45}
46export const fmtKb = (kb: number) => fmtBytes(kb * 1024)
47const cap1 = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
48const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
49const plural = (n: number, w: string, many = w + 's') => `${fmtInt(n)} ${n === 1 ? w : many}`
50
51/** `du -sk` output: "<kb>\t<path>" per line. */
52export function parseDu(out: string): Map<string, number> {
53  const m = new Map<string, number>()
54  for (const l of lines(out)) {
55    const hit = /^(\d+)\s+(.*)$/.exec(l)
56    if (hit) m.set(hit[2]!, Number(hit[1]))
57  }
58  return m
59}
60
61/** `git diff --name-status` output to display rows ("M  path", "R  old -> new"). */
62export function parseNameStatus(out: string): string[] {
63  return lines(out).map(l => {
64    const [st, ...paths] = l.split('\t')
65    return `${(st ?? '?').slice(0, 1)}  ${paths.join(' -> ')}`
66  })
67}
68
69/** `git clean -n` output: "Would remove <path>" per line. */
70export function parseCleanDryRun(out: string): string[] {
71  return lines(out).flatMap(l => {
72    const m = /^Would (?:remove|skip repository) (.*)$/.exec(l)
73    return m ? [m[1]!.replace(/\/$/, '')] : []
74  })
75}
76
77const cap = (title: string, rows: string[], max: number, flags: { subset?: boolean; extra?: boolean } = {}): Group => ({
78  title, rows: rows.slice(0, max), more: Math.max(0, rows.length - max), ...flags,
79})
80
81function short(path: string, cwd?: string): string {
82  if (cwd && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
83  return path
84}
85
86const REF = /^[A-Za-z0-9_@][A-Za-z0-9_.\-/@{}^~]*$/ // never starts with "-": a ref cannot become an option
87const chunks = <T>(a: T[], n: number) => Array.from({ length: Math.ceil(a.length / n) }, (_, k) => a.slice(k * n, (k + 1) * n))
88
89// ---- probes on paths ------------------------------------------------------------------
90
91const empty = (): Omit<Report, 'finding'> => ({ groups: [], summary: [], notes: [] })
92
93async function duMany(run: Run, paths: string[]): Promise<Map<string, number>> {
94  const all = new Map<string, number>()
95  for (const part of chunks(paths, 60))
96    for (const [p, kb] of parseDu((await run(['du', '-sk', ...part])).stdout)) all.set(p, kb)
97  return all
98}
99
100async function present(run: Run, paths: string[], extra: string[] = []): Promise<Set<string>> {
101  const found = new Set<string>()
102  for (const part of chunks(paths, 60))
103    for (const l of lines((await run(['find', ...part, '-maxdepth', '0', ...extra, '-print'])).stdout)) found.add(l)
104  return found
105}
106
107/** Expand a glob target with `find -path` at exactly the glob's depth (no shell involved). */
108export async function expandGlob(run: Run, t: Target): Promise<string[]> {
109  const parts = t.path!.split('/').filter(Boolean)
110  const g = parts.findIndex(p => /[*?[]/.test(p))
111  const baseParts = parts.slice(0, g)
112  const rest = parts.slice(g)
113  const base = '/' + baseParts.join('/')
114  const esc = (s: string) => s.replace(/[\\*?[]/g, c => '\\' + c)
115  const pattern = `${base === '/' ? '' : esc(base)}/${rest.join('/')}`
116  const res = await run(['find', base, '-mindepth', String(rest.length), '-maxdepth', String(rest.length), '-path', pattern])
117  return lines(res.stdout).filter(p => {
118    // Shell globs skip dot-files unless the pattern component itself starts with a dot.
119    const comps = p.split('/').filter(Boolean).slice(baseParts.length)
120    return comps.every((c, k) => !c.startsWith('.') || (rest[k] ?? '').startsWith('.'))
121  })
122}
123
124// folders: how the command treats a directory (default: it deletes it); false: no folder assessment
125const VERBS: Record<string, [string, string]> = { // verb -> [headline form, list title]
126  delete: ['deletes', 'Deletes'], empty: ['empties', 'Empties'], 'overwrite and destroy': ['shreds', 'Shreds'],
127  'change recursively': ['changes permissions of', 'Changes'], 'delete (untracked)': ['deletes', 'Deletes untracked'],
128  overwrite: ['overwrites', 'Overwrites'], 'overwrite or merge into': ['overwrites', 'Overwrites or merges into'],
129}
130type PathOpts = { verb: string; expandSingleDir: boolean; cap: number; risk?: boolean; folders?: Mode | false; canLower?: boolean }
131
132// Rows for a set of targets: kind, size, file count; for one directory also its biggest children.
133async function describePaths(run: Run, f: Finding, targets: Target[], o: PathOpts, cfg: Config): Promise<Omit<Report, 'finding'>> {
134  const r = empty()
135  const paths: string[] = []
136  for (const t of targets) {
137    if (!t.path) r.notes.push(`could not resolve ${t.raw}: ${t.unresolved}`)
138    else if (t.glob) {
139      const hits = await expandGlob(run, t)
140      if (hits.length === 0) r.notes.push(`${t.raw} matches nothing right now`)
141      paths.push(...hits)
142    } else paths.push(t.path)
143  }
144  const uniq = [...new Set(paths)].slice(0, o.cap)
145  if (paths.length > uniq.length) r.notes.push(`checked the first ${fmtInt(uniq.length)} of ${fmtInt(paths.length)} paths`)
146  if (uniq.length === 0) return r
147
148  const exists = await present(run, uniq)
149  const live = uniq.filter(p => exists.has(p))
150  const dirs = await present(run, live, ['-type', 'd'])
151  const sizes = await duMany(run, live)
152  const counts = await countFiles(run, [...dirs], r)
153  const links = o.folders === false ? new Set<string>() : await linksAmong(run, live, dirs)
154  const truncated = r.notes.some(n => n.startsWith('file counts are lower bounds'))
155  const rows = uniq.map(p => {
156    if (!exists.has(p)) return `${short(p, f.cwd)} · does not exist`
157    const kb = sizes.get(p)
158    const bits: string[] = links.has(p) ? ['symlink'] : []
159    if (counts?.has(p)) bits.push(plural(counts.get(p)!, 'file'))
160    if (kb !== undefined) bits.push(fmtKb(kb))
161    return `${short(p, f.cwd)}${dirs.has(p) ? '/' : ''}${bits.length ? ` · ${bits.join(', ')}` : ''}`
162  })
163  const [verbForm, title] = VERBS[o.verb] ?? [o.verb + 's', cap1(o.verb)]
164  r.groups.push(cap(title, rows, cfg.maxListed))
165  r.items = live.length
166  if (live.length === 1) r.action = `${verbForm} ${short(live[0]!, f.cwd)}${dirs.has(live[0]!) ? '/' : ''}`
167  else if (live.length > 1) r.action = `${verbForm} ${plural(live.length, dirs.size === live.length ? 'folder' : dirs.size === 0 ? 'file' : 'item')}`
168  const totalKb = [...sizes.values()].reduce((a, b) => a + b, 0)
169  const totalFiles = counts && dirs.size > 0 ? [...counts.values()].reduce((a, b) => a + b, 0) : undefined
170  r.summary.push(
171    live.length === 0
172      ? 'nothing exists at that path right now'
173      : [plural(live.length, 'item'), totalFiles !== undefined ? plural(totalFiles, 'file') : undefined, fmtKb(totalKb)]
174          .filter(Boolean).join(', '),
175  )
176  if (o.expandSingleDir && live.length === 1 && dirs.has(live[0]!)) await children(run, live[0]!, f, r, cfg)
177  // removing a symlink removes only the link: it is not "a file outside git" whose content is lost
178  const atRisk = o.folders === 'delete' || o.folders === undefined ? live.filter(p => !links.has(p)) : live
179  if (o.risk !== false && atRisk.length > 0) await addRisk(run, atRisk, r, cfg)
180  if (o.folders !== false && live.length > 0) {
181    const known = new Map<string, Known>([...dirs].map(d => [d, { kb: sizes.get(d), files: counts?.get(d), filesLowerBound: truncated }]))
182    await addFolders(run, f, r, live, dirs, links, { mode: o.folders ?? 'delete', canLower: o.canLower !== false, known }, cfg)
183  }
184  return r
185}
186
187// Symbolic links among the paths (find -type l does not follow them). Best effort: a failed probe means "none seen".
188async function linksAmong(run: Run, live: string[], dirs: Set<string>): Promise<Set<string>> {
189  try {
190    return await present(run, live.filter(p => !dirs.has(p)), ['-type', 'l'])
191  } catch {
192    return new Set()
193  }
194}
195
196// Folder risk for the directories (and symlinks) among the targets, merged into the overall RISK line. An add-on to the
197// listing: if it fails the listing and the file-level risk stand, and a note says so.
198async function addFolders(
199  run: Run, f: Finding, r: Omit<Report, 'finding'>, live: string[], dirs: Set<string> | undefined, linkSet: Set<string> | undefined,
200  o: { mode: Mode; canLower: boolean; known?: Map<string, Known> }, cfg: Config,
201): Promise<void> {
202  try {
203    const dirSet = dirs ?? (await present(run, live, ['-type', 'd']))
204    const links = linkSet ?? (await present(run, live.filter(p => !dirSet.has(p)), ['-type', 'l']))
205    const cands = [...dirSet, ...links]
206    if (cands.length === 0) return
207    const res = await assessFolders(run, cands, { home: cfg.home, cwd: cfg.cwd, runCwd: f.cwd, mode: o.mode, known: o.known })
208    r.folders = res.folders
209    if (res.skipped > 0) r.notes.push(`folder details for ${res.folders.length} of ${res.folders.length + res.skipped} folders`)
210    mergeFolderRisk(r, res.folders, o.canLower && res.skipped === 0 && cands.length === live.length)
211  } catch (err) {
212    r.notes.push(`folder details unavailable: ${(err as Error).message || 'probe failed'}`)
213  }
214}
215
216const LEVEL_RANK: Record<string, number> = { low: 0, medium: 1, high: 3 }
217
218/** The folder verdict can raise the overall level; it can lower MEDIUM to LOW only when it saw every target completely. */
219function mergeFolderRisk(r: Omit<Report, 'finding'>, folders: FolderRisk[], mayLower: boolean): void {
220  const worst = folders.reduce((a, b) => ((LEVEL_RANK[b.level] ?? -1) > (LEVEL_RANK[a.level] ?? -1) ? b : a))
221  const cur = r.risk
222  if (!cur) {
223    r.risk = { level: worst.level, headline: worst.headline }
224    return
225  }
226  if ((worst.level === 'high' && cur.level !== 'high') || (worst.level === 'medium' && cur.level === 'low'))
227    r.risk = { level: worst.level, headline: [worst.headline, cur.headline].filter(Boolean).join('; ') }
228  else if (mayLower && cur.level === 'medium' && folders.every(x => x.level === 'low' && x.complete))
229    r.risk = { level: 'low', headline: worst.headline }
230  // a plain folder holding nothing but safe repositories is "outside any repository" to the file-level check
231  else if (mayLower && cur.level === 'high' && folders.every(x => x.level === 'low' && x.complete && x.onlyRepos))
232    r.risk = { level: 'low', headline: worst.headline }
233}
234
235// Attach the data-loss assessment. assessLoss never throws; a failed probe yields level 'unknown'.
236async function addRisk(run: Run, live: string[], r: Omit<Report, 'finding'>, cfg: Config): Promise<void> {
237  const a = await assessLoss(run, live, cfg)
238  r.risk = a.risk
239  r.riskGroups = a.groups
240  r.notes.push(...a.notes)
241}
242
243// One find over all dirs: every non-directory under them, attributed to its dir by prefix.
244async function countFiles(run: Run, dirs: string[], r: Omit<Report, 'finding'>): Promise<Map<string, number> | undefined> {
245  if (dirs.length === 0) return new Map()
246  try {
247    const res = await run(['find', ...dirs, '!', '-type', 'd', '-print'])
248    const counts = new Map<string, number>(dirs.map(d => [d, 0]))
249    for (const l of lines(res.stdout)) for (const d of dirs) if (l.startsWith(d + '/')) counts.set(d, counts.get(d)! + 1)
250    if (res.isStdoutTruncated) r.notes.push('file counts are lower bounds (listing truncated)')
251    return counts
252  } catch (err) {
253    r.notes.push(`file count unavailable: ${(err as Error).message}`)
254    return undefined
255  }
256}
257
258async function children(run: Run, dir: string, f: Finding, r: Omit<Report, 'finding'>, cfg: Config): Promise<void> {
259  try {
260    const kids = lines((await run(['find', dir, '-mindepth', '1', '-maxdepth', '1'])).stdout).slice(0, 500)
261    const dirs = await present(run, kids, ['-type', 'd'])
262    const sizes = await duMany(run, kids)
263    const rows = kids
264      .map(k => ({ k, kb: sizes.get(k) ?? 0 }))
265      .sort((a, b) => b.kb - a.kb)
266      .map(({ k, kb }) => `${short(k, dir)}${dirs.has(k) ? '/' : ''} · ${fmtKb(kb)}`)
267    if (rows.length) r.groups.push(cap(`Largest inside ${short(dir, f.cwd)}/`, rows, cfg.maxListed, { extra: true }))
268  } catch (err) {
269    r.notes.push(`could not list the contents: ${(err as Error).message}`)
270  }
271}
272
273// ---- per-kind measurement ---------------------------------------------------------------
274
275const pos = (args: string[]) => {
276  const dd = args.indexOf('--')
277  return (dd < 0 ? args : args.slice(0, dd)).filter(a => !a.startsWith('-')).concat(dd < 0 ? [] : args.slice(dd + 1))
278}
279
280async function git(run: Run, cwd: string | undefined, ...argv: string[]): Promise<string> {
281  const res = await run(['git', '--no-optional-locks', '--no-pager', ...argv], cwd)
282  if (res.exitCode !== 0) throw new Error(`git ${argv[0]} failed: ${res.stderr.trim().split('\n')[0] || `exit ${res.exitCode}`}`)
283  return res.stdout
284}
285
286export async function measure(run: Run, f: Finding, cfg: Config): Promise<Report> {
287  try {
288    return { finding: f, ...(await dispatch(run, f, cfg)) }
289  } catch (err) {
290    return { finding: f, groups: [], summary: [], notes: [], unmeasured: (err as Error).message || 'probe failed' }
291  }
292}
293
294async function dispatch(run: Run, f: Finding, cfg: Config): Promise<Omit<Report, 'finding'>> {
295  const dp = (verb: string, targets = f.targets, expandSingleDir = true) =>
296    describePaths(run, f, targets, { verb, expandSingleDir, cap: 500 }, cfg)
297  switch (f.kind) {
298    case 'rm': {
299      if (f.viaXargs || f.targets.length === 0)
300        return { ...empty(), unmeasured: 'the paths arrive on stdin (xargs) or are not named' }
301      return dp('delete')
302    }
303    case 'truncate': return dp('empty', f.targets, false)
304    case 'shred': return dp('overwrite and destroy', f.targets, false)
305    case 'chmod': return describePaths(run, f, f.targets, { verb: 'change recursively', expandSingleDir: true, cap: 500, risk: false, folders: 'modify' }, cfg)
306    case 'r': {
307      const r = await dp('delete')
308      if (f.targets.length === 0) r.notes.push('the paths in the R code are computed: could not name them')
309      return r
310    }
311    case 'cargo-clean': return cargoClean(run, f, cfg)
312    case 'redirect': return redirect(run, f, cfg)
313    case 'overwrite': return overwrite(run, f, cfg)
314    case 'dd': return dd(run, f, cfg)
315    case 'mkfs': return { ...empty(), unmeasured: 'a device is erased; its contents are not inspected' }
316    case 'find': return findRule(run, f, cfg)
317    case 'git-clean': return gitClean(run, f, cfg)
318    case 'git-reset': return gitReset(run, f, cfg)
319    case 'git-discard': return gitDiscard(run, f, cfg)
320    case 'git-push': return gitPush(run, f, cfg)
321    case 'git-branch': return gitBranch(run, f, cfg)
322    case 'git-stash': return gitStash(run, f, cfg)
323    case 'sql': return { ...empty(), groups: [{ title: 'Statements', rows: f.args, more: 0 }], unmeasured: 'database contents are not inspected' }
324    case 'custom': return { ...empty(), action: 'matches your extra pattern', unmeasured: 'nothing measured for your extra patterns' }
325    case 'opaque': return { ...empty(), unmeasured: f.reason }
326  }
327}
328
329async function cargoClean(run: Run, f: Finding, cfg: Config) {
330  const a = f.args
331  const flag = (n: string) => { const k = a.indexOf(n); return k >= 0 ? a[k + 1] : a.find(x => x.startsWith(n + '='))?.slice(n.length + 1) }
332  const dir = flag('--target-dir')
333  const manifest = flag('--manifest-path')
334  const abs = (p: string) => (p.startsWith('/') ? normalize(p) : f.cwd ? normalize(`${f.cwd}/${p}`) : undefined)
335  const where = dir ? abs(dir) : manifest ? abs(manifest.replace(/[^/]*$/, '') + 'target') : f.cwd ? `${f.cwd}/target` : undefined
336  if (!where) return { ...empty(), unmeasured: 'the working directory is not known' }
337  const r = await describePaths(run, f, [{ raw: where, path: where, glob: false }], { verb: 'delete', expandSingleDir: true, cap: 1, risk: false }, cfg)
338  r.notes.push('cargo may clean the workspace\'s target/ or CARGO_TARGET_DIR instead')
339  return r
340}
341
342async function redirect(run: Run, f: Finding, cfg: Config) {
343  const p = f.targets[0]!.path!
344  // find -L <p> -maxdepth 0 -type f -size +0c : prints p only for a non-empty regular file.
345  const hit = lines((await run(['find', '-L', p, '-maxdepth', '0', '-type', 'f', '-size', '+0c', '-print'])).stdout)
346  if (hit.length === 0) return { ...empty(), skip: true }
347  return describePaths(run, f, f.targets, { verb: 'overwrite', expandSingleDir: false, cap: 1 }, cfg)
348}
349
350async function overwrite(run: Run, f: Finding, cfg: Config) {
351  const dest = f.targets[0]!.path!
352  const cands = [dest, ...f.args.map(s => `${dest}/${s.slice(s.lastIndexOf('/') + 1)}`)]
353  const hits = [...(await present(run, cands, ['-type', 'f']))]
354  if (hits.length === 0) {
355    const dirs = await overwrittenDirs(run, f, dest)
356    if (dirs.length === 0) return { ...empty(), skip: true }
357    const targets = dirs.map(p => ({ raw: p, path: p, glob: false }))
358    return describePaths(run, f, targets, { verb: 'overwrite or merge into', expandSingleDir: false, cap: 100, folders: 'overwrite', canLower: false }, cfg)
359  }
360  const targets = hits.map(p => ({ raw: p, path: p, glob: false }))
361  return describePaths(run, f, targets, { verb: 'overwrite', expandSingleDir: false, cap: 100 }, cfg)
362}
363
364// `cp -rf src dest` / `mv -f src dest` where dest is a folder that already holds a folder named like src: its files are replaced.
365async function overwrittenDirs(run: Run, f: Finding, dest: string): Promise<string[]> {
366  const abs = f.args.filter(a => !/[*?[$`~]/.test(a)).map(a => (a.startsWith('/') ? normalize(a) : f.cwd ? normalize(`${f.cwd}/${a}`) : '')).filter(Boolean)
367  if (abs.length === 0 || (await present(run, [dest], ['-type', 'd'])).size === 0) return []
368  const srcDirs = await present(run, abs, ['-type', 'd'])
369  const inDest = [...srcDirs].map(p => `${dest}/${p.slice(p.lastIndexOf('/') + 1)}`)
370  return inDest.length ? [...(await present(run, inDest, ['-type', 'd']))] : []
371}
372
373async function dd(run: Run, f: Finding, cfg: Config) {
374  const t = f.targets[0]!
375  if (t.path?.startsWith('/dev/'))
376    return { ...empty(), action: `writes over ${t.path}`, notes: ['Everything on the device is overwritten'], unmeasured: 'device contents are not inspected' }
377  return describePaths(run, f, f.targets, { verb: 'overwrite', expandSingleDir: false, cap: 1 }, cfg)
378}
379
380// find ... -delete / -exec rm  ->  the same find with the destructive part swapped for -print.
381export function findPreviewArgs(args: string[]): { argv: string[]; note?: string } | undefined {
382  const out: string[] = []
383  let note: string | undefined
384  for (let i = 0; i < args.length; i++) {
385    const t = args[i]!
386    if (/^-(fprint0?|fprintf|fls)$/.test(t)) return undefined // would write a file
387    if (t === '-delete') out.push('-print')
388    else if (/^-(exec|execdir|ok|okdir)$/.test(t)) {
389      let j = i + 1
390      while (j < args.length && args[j] !== ';' && args[j] !== '+') j++
391      const cmd = (args[i + 1] ?? '').slice((args[i + 1] ?? '').lastIndexOf('/') + 1)
392      if (/^(rm|unlink|shred|rmdir|sh|bash|zsh)$/.test(cmd)) out.push('-print')
393      else note = 'an -exec filter was left out, so the list may include extra paths'
394      i = j
395    } else out.push(t)
396  }
397  return { argv: out, ...(note ? { note } : {}) }
398}
399
400async function findRule(run: Run, f: Finding, cfg: Config) {
401  if (f.args.some(a => /[$`]/.test(a))) return { ...empty(), unmeasured: 'the find arguments contain shell expansions' }
402  const prev = findPreviewArgs(f.args)
403  if (!prev) return { ...empty(), unmeasured: 'the find writes files itself (-fprint/-fls): not previewed' }
404  const res = await run(['find', ...prev.argv], f.cwd)
405  const abs = (p: string) => (p.startsWith('/') ? p : f.cwd ? normalize(`${f.cwd}/${p}`) : p)
406  const found = lines(res.stdout).map(abs)
407  const r = empty()
408  if (prev.note) r.notes.push(prev.note)
409  if (res.exitCode !== 0 && found.length === 0) throw new Error(`find failed: ${res.stderr.trim().split('\n')[0] || `exit ${res.exitCode}`}`)
410  if (res.isStdoutTruncated) r.notes.push('match list truncated: counts are lower bounds')
411  const sized = found.slice(0, 300)
412  const sizes = await duMany(run, sized)
413  const rows = sized.map(p => `${short(p, f.cwd)}  ${fmtKb(sizes.get(p) ?? 0)}`).concat(found.slice(300).map(p => short(p, f.cwd)))
414  r.groups.push(cap('Deletes', rows, cfg.maxListed))
415  r.action = `deletes ${plural(found.length, 'match', 'matches')}`
416  const kb = [...sizes.values()].reduce((a, b) => a + b, 0)
417  r.summary.push(`${plural(found.length, 'match', 'matches')}, ${fmtKb(kb)}${found.length > sized.length ? ' (first 300 sized)' : ''}`)
418  if (found.length > 0) await addRisk(run, found.slice(0, 500), r, cfg)
419  if (found.length > 0) {
420    // a match that is a directory is deleted with everything in it (find -delete only when it is empty by then)
421    const matched = found.slice(0, 500)
422    await addFolders(run, f, r, matched, undefined, undefined, { mode: 'delete', canLower: true }, cfg)
423  }
424  return r
425}
426
427// ---- git ------------------------------------------------------------------------------------
428
429async function gitClean(run: Run, f: Finding, cfg: Config) {
430  const a = f.args
431  const letters = new Set(a.filter(x => /^-[^-]/.test(x)).flatMap(x => [...x.slice(1)]))
432  const probe = ['clean', '-n', ...['d', 'x', 'X'].filter(l => letters.has(l)).map(l => '-' + l)]
433  const paths: string[] = []
434  let afterDashes = false
435  for (let i = 0; i < a.length; i++) {
436    const x = a[i]!
437    if (afterDashes) paths.push(x)
438    else if (x === '--') afterDashes = true
439    else if (x === '-e' || x === '--exclude') probe.push('-e', a[++i] ?? '')
440    else if (x.startsWith('--exclude=')) probe.push(x)
441    else if (!x.startsWith('-')) paths.push(x)
442  }
443  const out = await git(run, f.cwd, ...probe, '--', ...paths)
444  const rel = parseCleanDryRun(out)
445  if (!f.cwd) throw new Error('the working directory is not known')
446  const targets = rel.map(p => ({ raw: p, path: normalize(`${f.cwd}/${p}`), glob: false }))
447  if (targets.length === 0) return { ...empty(), notes: ['git clean -n finds nothing to remove right now'] }
448  const r = await describePaths(run, f, targets, { verb: 'delete (untracked)', expandSingleDir: false, cap: 300, canLower: false }, cfg)
449  r.action = `deletes ${plural(targets.length, 'untracked path')}`
450  if (!letters.has('x') && !letters.has('X')) r.notes.push('Ignored files are kept (no -x)')
451  r.risk = { level: 'high', headline: `${plural(targets.length, 'untracked path')} never committed, gone for good${letters.has('x') || letters.has('X') ? ' (includes ignored files)' : ''}` }
452  return r
453}
454
455async function gitReset(run: Run, f: Finding, cfg: Config) {
456  const ref = pos(f.args).find(a => REF.test(a)) ?? 'HEAD'
457  if (!REF.test(ref)) throw new Error(`unusual ref "${ref}"`)
458  const files = parseNameStatus(await git(run, f.cwd, 'diff', '--name-status', ref))
459  const stat = (await git(run, f.cwd, 'diff', '--shortstat', ref)).trim()
460  const lost = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${ref}..HEAD`))
461  const r = empty()
462  r.groups.push(cap('Changes lost', files, cfg.maxListed))
463  if (lost.length) r.groups.push(cap('Commits leaving the branch', lost, cfg.maxListed))
464  r.action = `discards uncommitted changes${files.length ? ` in ${plural(files.length, 'file')}` : ''}`
465  r.summary.push(stat || 'no uncommitted changes to tracked files')
466  r.notes.push('Untracked files are not touched')
467  r.risk = files.length
468    ? { level: 'high', headline: `uncommitted changes to ${plural(files.length, 'tracked file')}, gone for good (not in the reflog)` }
469    : lost.length ? { level: 'medium', headline: 'commits leave the branch; git reflog can bring them back' }
470    : { level: 'low', headline: 'nothing uncommitted to lose' }
471  return r
472}
473
474async function gitDiscard(run: Run, f: Finding, cfg: Config) {
475  const a = f.args
476  const dd = a.indexOf('--')
477  const before = (dd < 0 ? a : a.slice(0, dd)).filter(x => !x.startsWith('-'))
478  const after = dd < 0 ? [] : a.slice(dd + 1)
479  const source = a.find(x => x.startsWith('--source='))?.slice(9)
480  const tree = source ?? (dd >= 0 ? before.find(x => REF.test(x)) : undefined)
481  if (tree !== undefined && !REF.test(tree)) throw new Error(`unusual ref "${tree}"`)
482  const paths = after.length ? after : dd < 0 ? before : []
483  const spec = ['--name-status', ...(tree ? [tree] : paths.length ? [] : ['HEAD']), '--', ...(paths.length ? paths : ['.'])]
484  const files = parseNameStatus(await git(run, f.cwd, 'diff', ...spec))
485  const stat = (await git(run, f.cwd, 'diff', '--shortstat', ...spec.filter(s => s !== '--name-status'))).trim()
486  const r = empty()
487  r.groups.push(cap('Changes lost', files, cfg.maxListed))
488  r.action = `discards uncommitted changes${files.length ? ` in ${plural(files.length, 'file')}` : ''}`
489  r.summary.push(stat || 'no uncommitted changes to tracked files here')
490  r.notes.push('Untracked files are not touched')
491  r.risk = files.length
492    ? { level: 'high', headline: `uncommitted changes to ${plural(files.length, 'tracked file')}, gone for good` }
493    : { level: 'low', headline: 'nothing uncommitted to lose here' }
494  return r
495}
496
497async function gitPush(run: Run, f: Finding, cfg: Config) {
498  const a = f.args
499  if (a.some(x => ['--all', '--mirror', '--tags'].includes(x))) throw new Error('pushes every ref: cannot enumerate')
500  const operands = pos(a)
501  const remote = operands[0]
502  const specs = operands.slice(1)
503  const pairs = specs.length
504    ? specs.map(s => {
505        const body = s.replace(/^\+/, '')
506        const [src, dst] = body.includes(':') ? [body.split(':')[0]!, body.split(':').slice(1).join(':')] : [body, body]
507        return { src, dst: dst.replace(/^refs\/heads\//, '') }
508      })
509    : [{ src: 'HEAD', dst: '' }]
510  const deleting = a.includes('--delete') || a.includes('-d')
511  const r = empty()
512  for (const p of pairs) {
513    const src = deleting && p.src === p.dst ? '' : p.src
514    const dstRef = p.dst === '' || !remote ? '@{u}' : `${remote}/${p.dst}`
515    for (const ref of [src, dstRef]) if (ref !== '' && !REF.test(ref)) throw new Error(`unusual ref "${ref}"`)
516    if (src === '') {
517      const tip = lines(await git(run, f.cwd, 'log', '--oneline', '-n', '5', dstRef))
518      r.groups.push(cap(`${dstRef} is deleted; latest commits`, tip, cfg.maxListed))
519      r.action = `deletes ${dstRef} on the remote`
520      continue
521    }
522    const over = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${src}..${dstRef}`))
523    const sent = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${dstRef}..${src}`))
524    r.groups.push(cap(`Overwritten on ${dstRef}`, over, cfg.maxListed))
525    r.groups.push(cap(`Sent from ${src}`, sent, cfg.maxListed))
526    r.action ??= `force-pushes over ${dstRef}`
527    r.summary.push(`${plural(over.length, 'remote commit')} lost, ${plural(sent.length, 'commit')} sent`)
528    if (over.length) r.risk = { level: 'high', headline: `${plural(over.length, 'remote commit')} overwritten for anyone who has not pulled them` }
529  }
530  r.notes.push('Remote state is as of your last fetch')
531  return r
532}
533
534async function gitBranch(run: Run, f: Finding, cfg: Config) {
535  const r = empty()
536  for (const b of pos(f.args)) {
537    if (!REF.test(b)) throw new Error(`unusual branch name "${b}"`)
538    const only = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), b, '--not', `--exclude=${b}`, '--branches', '--tags', '--remotes'))
539    const tip = (await git(run, f.cwd, 'rev-parse', '--short', b)).trim()
540    r.groups.push(cap(
541      only.length ? `Only on ${b}` : `${b}: every commit is also elsewhere`, only, cfg.maxListed,
542    ))
543    r.notes.push(`Undo: git branch ${b} ${tip}`)
544    r.action = `deletes branch ${pos(f.args).join(', ')}`
545    if (only.length) r.risk = { level: 'high', headline: `${plural(only.length, 'commit')} on ${b} exist on no other branch, tag or remote` }
546  }
547  return r
548}
549
550async function gitStash(run: Run, f: Finding, cfg: Config) {
551  const op = f.args[0]
552  const r = empty()
553  if (op === 'clear') {
554    const all = lines(await git(run, f.cwd, 'stash', 'list'))
555    r.groups.push(cap('Stashes deleted', all, cfg.maxListed))
556    r.action = 'deletes all stashes'
557    r.summary.push(plural(all.length, 'stash', 'stashes'))
558    r.risk = { level: 'high', headline: `${plural(all.length, 'stash', 'stashes')} of uncommitted work exist nowhere else` }
559    return r
560  }
561  const ref = f.args.slice(1).find(x => !x.startsWith('-')) ?? 'stash@{0}'
562  if (!/^stash@\{\d+\}$/.test(ref)) throw new Error(`unusual stash ref "${ref}"`)
563  const stat = lines(await git(run, f.cwd, 'stash', 'show', '--stat', ref))
564  r.groups.push(cap(`${ref} holds`, stat.slice(0, -1), cfg.maxListed))
565  r.action = `deletes ${ref}`
566  r.summary.push(stat.at(-1)?.trim() ?? ref)
567  r.notes.push('git fsck --unreachable can still find a dropped stash until gc runs')
568  r.risk = { level: 'high', headline: 'stashed work exists nowhere else' }
569  return r
570}
571
572export async function measureAll(run: Run, findings: Finding[], cfg: Config, max = 6): Promise<{ reports: Report[]; unlisted: number }> {
573  const reports: Report[] = []
574  for (const f of findings.slice(0, max)) {
575    const rep = await measure(run, f, cfg)
576    if (!rep.skip) reports.push(rep)
577  }
578  return { reports, unlisted: Math.max(0, findings.length - max) }
579}
580
hooks/windows.ts 20 lines
1// Native Windows handling: the measurer's probes are Unix `find` and `du`, which do not exist there (Windows has its own
2// `find`, a text search, that would answer nothing and make a destructive command look harmless). So on Windows a risky
3// command is held WITHOUT a listing, saying so, instead of being measured.
4import type { Finding } from './classify'
5import type { Report } from './measure'
6
7export const WINDOWS_NOTE = 'Nothing can be listed on Windows. Check the path yourself first'
8
9/** A drive-letter or UNC path: `C:\Users\me`, `C:/Users/me`, `\\server\share`. */
10export const looksWindows = (p: string | undefined): boolean => p !== undefined && /^([A-Za-z]:[\\/]|\\\\)/.test(p)
11
12/** Reports that say plainly nothing was measured. Conditional findings (`> file`) need a file check, so they pass. */
13export function unmeasured(found: Finding[]): { reports: Report[]; unlisted: number } {
14  const held = found.filter(f => f.conditional === undefined)
15  return {
16    reports: held.slice(0, 6).map(f => ({ finding: f, groups: [], summary: [], notes: [WINDOWS_NOTE], unmeasured: 'native Windows' })),
17    unlisted: Math.max(0, held.length - 6),
18  }
19}
20
hooks/lex.ts 188 lines
1// A small POSIX-shell lexer: just enough to find the simple commands in a Bash
2// tool call. It never evaluates anything. Quotes are removed, `$(...)`, backticks
3// and `<(...)` bodies are captured as `subs` (classified recursively), and anything
4// that expands at run time (`$VAR`, `${..}`, `{a,b}`, `$(..)`) marks the word `dynamic`.
5
6export type Word = {
7  text: string
8  glob: boolean // an unquoted * ? or [..] is in it
9  dynamic: boolean // its value is only known at run time
10  tilde: boolean // starts with an unquoted ~
11  subs: string[] // command substitutions found inside it
12}
13
14export type Tok =
15  | { t: 'word'; w: Word }
16  | { t: 'op'; op: ';' | '&&' | '||' | '|' | '&' | '(' | ')' }
17  | { t: 'redir'; op: string; fd?: string; body?: string }
18
19const blank = (): Word => ({ text: '', glob: false, dynamic: false, tilde: false, subs: [] })
20const NAME = /[A-Za-z0-9_]/
21// Sticky regexes: tested in place, so a long command is not re-sliced at every bracket.
22const BRACKET = /\[[^\s\]]+\]/y
23const BRACE = /\{[^\s{}]*(,|\.\.)[^\s{}]*\}/y
24const REDIR = /(&>>|&>|>>|>\||>&|<<<|<<-|<<|<&|<>|>|<)/y
25const at = (re: RegExp, s: string, i: number) => ((re.lastIndex = i), re.exec(s))
26
27// Index of the `close` char matching an already-opened `open`, quote aware; -1 if none.
28function matchClose(src: string, from: number, open: string, close: string): number {
29  let depth = 1
30  for (let i = from; i < src.length; i++) {
31    const c = src[i]
32    if (c === '\\') i++
33    else if (c === "'") {
34      const j = src.indexOf("'", i + 1)
35      if (j < 0) return -1
36      i = j
37    } else if (c === '"') {
38      let j = i + 1
39      while (j < src.length && src[j] !== '"') j += src[j] === '\\' ? 2 : 1
40      if (j >= src.length) return -1
41      i = j
42    } else if (c === close) {
43      if (open === close || --depth === 0) return i
44    } else if (c === open) depth++
45  }
46  return -1
47}
48
49type Pending = { tok: Extract<Tok, { t: 'redir' }>; delim: string; strip: boolean }
50
51export function lex(src: string): Tok[] {
52  const toks: Tok[] = []
53  const heredocs: Pending[] = []
54  let cur: Word | undefined
55  let expectDelim: { tok: Extract<Tok, { t: 'redir' }>; strip: boolean } | undefined
56  let i = 0
57
58  const word = () => (cur ??= blank())
59  const flush = () => {
60    if (!cur) return
61    toks.push({ t: 'word', w: cur })
62    if (expectDelim) heredocs.push({ ...expectDelim, delim: cur.text })
63    expectDelim = undefined
64    cur = undefined
65  }
66  const op = (o: Extract<Tok, { t: 'op' }>['op']) => {
67    flush()
68    toks.push({ t: 'op', op: o })
69  }
70  // `$(..)`, backticks, `<(..)`: the body is kept for recursive classification.
71  const capture = (open: string, close: string, openAt: number, rawFrom: number): number => {
72    const end = matchClose(src, openAt + 1, open, close)
73    const stop = end < 0 ? src.length : end
74    const w = word()
75    w.subs.push(src.slice(openAt + 1, stop))
76    w.dynamic = true
77    w.text += src.slice(rawFrom, stop + 1)
78    return stop
79  }
80  const readBodies = () => {
81    for (const h of heredocs.splice(0)) {
82      const lines: string[] = []
83      while (i < src.length) {
84        const nl = src.indexOf('\n', i)
85        const line = src.slice(i, nl < 0 ? src.length : nl)
86        i = nl < 0 ? src.length : nl + 1
87        if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim) break
88        lines.push(line)
89      }
90      h.tok.body = lines.join('\n')
91    }
92  }
93  // A `$` outside single quotes. `quoted` = inside double quotes.
94  const dollar = (quoted: boolean) => {
95    const n = src[i + 1]
96    if (n === '(') i = capture('(', ')', i + 1, i)
97    else if (n === '{') {
98      const end = matchClose(src, i + 2, '{', '}')
99      const stop = end < 0 ? src.length - 1 : end
100      const w = word()
101      w.text += src.slice(i, stop + 1)
102      w.dynamic = true
103      i = stop
104    } else if (n === "'" && !quoted) {
105      const end = src.indexOf("'", i + 2)
106      word().text += src.slice(i + 2, end < 0 ? src.length : end)
107      i = end < 0 ? src.length : end
108    } else if (n !== undefined && (NAME.test(n) || '@*#?!$-'.includes(n))) {
109      let j = i + 1
110      if (NAME.test(n)) while (j + 1 < src.length && NAME.test(src[j + 1]!)) j++
111      const w = word()
112      w.text += src.slice(i, j + 1)
113      w.dynamic = true
114      i = j
115    } else word().text += '$'
116  }
117
118  while (i < src.length) {
119    const c = src[i]!
120    if (c === ' ' || c === '\t' || c === '\r') flush()
121    else if (c === '\n') {
122      op(';')
123      i++
124      readBodies()
125      continue
126    } else if (c === '#' && !cur) {
127      while (i < src.length && src[i] !== '\n') i++
128      continue
129    } else if (c === '\\') {
130      if (src[i + 1] === '\n') i++
131      else {
132        word().text += src[i + 1] ?? ''
133        i++
134      }
135    } else if (c === "'") {
136      const end = src.indexOf("'", i + 1)
137      word().text += src.slice(i + 1, end < 0 ? src.length : end)
138      i = end < 0 ? src.length : end
139    } else if (c === '"') {
140      word()
141      i++
142      while (i < src.length && src[i] !== '"') {
143        const d = src[i]!
144        if (d === '\\' && '$"`\\\n'.includes(src[i + 1] ?? 'x')) {
145          if (src[i + 1] !== '\n') word().text += src[i + 1]
146          i++
147        } else if (d === '$') dollar(true)
148        else if (d === '`') i = capture('`', '`', i, i)
149        else word().text += d
150        i++
151      }
152    } else if (c === '`') i = capture('`', '`', i, i)
153    else if (c === '$') dollar(false)
154    else if ((c === '<' || c === '>') && src[i + 1] === '(') i = capture('(', ')', i + 1, i)
155    else if (c === '<' || c === '>' || (c === '&' && src[i + 1] === '>')) {
156      const fd = cur && /^\d+$/.test(cur.text) ? cur.text : undefined
157      if (fd !== undefined) cur = undefined
158      flush()
159      const m = at(REDIR, src, i)!
160      const tok: Extract<Tok, { t: 'redir' }> = { t: 'redir', op: m[1]!, ...(fd ? { fd } : {}) }
161      toks.push(tok)
162      if (tok.op === '<<' || tok.op === '<<-') expectDelim = { tok, strip: tok.op === '<<-' }
163      i += m[1]!.length - 1
164    } else if (c === ';') op(';')
165    else if (c === '&') {
166      if (src[i + 1] === '&') (op('&&'), i++)
167      else op('&')
168    } else if (c === '|') {
169      if (src[i + 1] === '|') (op('||'), i++)
170      else {
171        op('|')
172        if (src[i + 1] === '&') i++
173      }
174    } else if (c === '(' || c === ')') op(c)
175    else {
176      const w = word()
177      if (c === '*' || c === '?') w.glob = true
178      else if (c === '[' && at(BRACKET, src, i)) w.glob = true
179      else if (c === '{' && at(BRACE, src, i)) w.dynamic = true
180      else if (c === '~' && w.text === '') w.tilde = true
181      w.text += c
182    }
183    i++
184  }
185  flush()
186  return toks
187}
188
hooks/folder.ts 574 lines
1// Folder risk: for every DIRECTORY a held command would delete, overwrite or change recursively, answer in a few
2// short rows: where the folder sits (repository root, folder inside a repository, outside any repository), what kind of
3// content it holds (build output that can be regenerated, source, data that is hard to regenerate, likely secrets),
4// how big it is and how recently something in it changed, what git could give back, and ONE closing row
5// "Recoverable: Yes / Partly / No" that also sets the risk level.
6//
7// Everything is a heuristic from names and git state, and the wording says so. Probes are read-only argv arrays through
8// `run` (find, du, git: nothing a shell could evaluate), bounded three ways: a probe timeout, a cap on how many files
9// are classified ("sampled" is said when it bites), and a stop at the first tree walk that times out.
10// Contract like the rest of the measurer: never throws; a scan that did not finish never reads as LOW.
11import { GIT, statusEntries, wholeRepoFact } from './recover'
12import type { RiskLevel } from './recover'
13import type { Run, RunResult } from './measure'
14
15export type Mode = 'delete' | 'modify' | 'overwrite' // what the command does to the folder
16export type Recoverable = 'yes' | 'partly' | 'no' | 'unknown'
17export type Row = [label: string, value: string]
18export type FolderRisk = {
19  path: string
20  title: string // "data/ · High"
21  level: RiskLevel
22  rows: Row[] // [label, value] pairs, already worded; the last one is "Recoverable"
23  headline: string // one short phrase for the overall impact line (no full stop)
24  regenerable: boolean // true only when everything found is build/cache output by name
25  onlyRepos?: boolean // a folder outside git that holds nothing but whole repositories (all of them safe)
26  complete: boolean // false when the scan stopped early or failed: such a result never lowers a risk level
27}
28export type Known = { kb?: number; files?: number; filesLowerBound?: boolean }
29export type FolderContext = {
30  home?: string
31  cwd?: string // the session's directory
32  runCwd?: string // the directory the command runs in (after a `cd`)
33  mode: Mode
34  maxFolders?: number
35  known?: Map<string, Known> // sizes and counts the caller already measured
36  note?: string // e.g. cargo clean: the caller says this is build output
37}
38
39export const SAMPLE_CAP = 3000 // files classified at most; more is "sampled"
40export const FOLDER_PROBE_MS = 8000
41const SIZE_CAP = 240 // files sized with du, data and secrets first
42const NESTED_CAP = 5
43const BANDS: [number, string][] = [
44  [15, 'within 15 minutes'], [60, 'within the hour'], [360, 'within 6 hours'],
45  [1440, 'within a day'], [10080, 'within a week'], [43200, 'within 30 days'],
46]
47const LEVEL_WORD: Record<RiskLevel, string> = { high: 'High', medium: 'Medium', low: 'Low', unknown: 'Unknown' }
48const cap1 = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
49
50const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
51const fmt = (n: number) => n.toLocaleString('en-US')
52const plural = (n: number, w: string, many = w + 's') => `${fmt(n)} ${n === 1 ? w : many}`
53const inside = (child: string, parent: string) => child === parent || child.startsWith(parent === '/' ? '/' : parent + '/')
54const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
55const parentOf = (p: string) => (p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/')
56const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
57const sum = (a: number[]) => a.reduce((x, y) => x + y, 0)
58const chunks = <T>(a: T[], n: number) => Array.from({ length: Math.ceil(a.length / n) }, (_, k) => a.slice(k * n, (k + 1) * n))
59export function kbText(kb: number): string {
60  const u = ['KB', 'MB', 'GB', 'TB']
61  let v = kb
62  let k = 0
63  while (v >= 1024 && k < u.length - 1) (v /= 1024, k++)
64  return `${+v.toFixed(v >= 10 || k === 0 ? 0 : 1)} ${u[k]}`
65}
66function duMap(out: string): Map<string, number> {
67  const m = new Map<string, number>()
68  for (const l of lines(out)) {
69    const hit = /^(\d+)\s+(.*)$/.exec(l)
70    if (hit) m.set(hit[2]!, Number(hit[1]))
71  }
72  return m
73}
74
75// ---- names: what a folder or file probably is -------------------------------------------------
76
77export type Cls = 'regen' | 'data' | 'secret' | 'source' | 'other'
78
79/** Directory names that are build or cache output almost everywhere (pruned from the file listing, sized as a whole). */
80export const REGEN_NAMES = [
81  'node_modules', '__pycache__', '.venv', 'venv', '.cache', '.Rproj.user', '.pytest_cache', '.mypy_cache', '.ruff_cache',
82  '.tox', '.gradle', '.next', '.nuxt', '.ipynb_checkpoints', '.eggs', '.parcel-cache', '.sass-cache', '.Rcache',
83  '*.Rcheck', '*.egg-info', 'target', 'build', 'dist', '_build', '_site', 'htmlcov',
84]
85// Build output only by convention: a folder called build or dist can also hold something somebody wrote.
86const GENERIC = new Set(['target', 'build', 'dist', '_build', '_site', '.cache'])
87const RENV_LIBRARY = '*/renv/library' // R package library managed by renv
88
89const regenName = (name: string) => REGEN_NAMES.some(n => (n.startsWith('*') ? name.endsWith(n.slice(1)) : name === n))
90
91/** Which part of the folder's own path makes all of it build output (its name or a specific parent), or undefined. */
92export function regenContext(dir: string): string | undefined {
93  const parts = dir.split('/').filter(Boolean)
94  for (let k = 0; k < parts.length; k++) {
95    const p = parts[k]!
96    if (p === 'library' && parts[k - 1] === 'renv') return 'renv/library'
97    if (regenName(p) && (k === parts.length - 1 || !GENERIC.has(p))) return p
98  }
99  return undefined
100}
101
102const DATA_EXT = new Set((
103  'csv tsv rds rda rdata vcf bcf bam cram sam bai fam bed bim ped map gds bgen pgen pvar psam h5 hdf5 hdf parquet feather ' +
104  'arrow xlsx xls ods fasta fa fna faa fastq fq gtf gff gff3 gz bz2 xz zst zip tar tgz 7z sqlite sqlite3 db ipynb tex bib ' +
105  'docx doc odt pdf pptx key npy npz mat sav dta sas7bdat rdb rdx qs fst'
106).split(' '))
107const SOURCE_EXT = new Set((
108  'r py rs js mjs ts tsx jsx c h cc cpp hpp java go sh bash zsh jl sql md rmd qmd rst toml yaml yml json cfg ini css html ' +
109  'cmake lock txt nf smk pl rb'
110).split(' '))
111const SOURCE_NAMES = new Set(['makefile', 'dockerfile', 'license', 'readme', 'description', 'namespace', 'snakefile'])
112
113/** Name-only guess for one file. Never reads the file. */
114export function classifyName(name: string): Exclude<Cls, 'regen'> {
115  const n = name.toLowerCase()
116  if (n === '.env' || n.startsWith('.env.') || /\.(pem|key|p12|pfx|kdbx|keystore)$/.test(n)) return 'secret'
117  if (['.netrc', '.npmrc', '.pypirc', '.renviron', 'credentials'].includes(n)) return 'secret'
118  if (n.startsWith('id_') && !n.endsWith('.pub')) return 'secret'
119  const ext = n.includes('.') ? n.slice(n.lastIndexOf('.') + 1) : ''
120  if (DATA_EXT.has(ext)) return 'data'
121  if (SOURCE_EXT.has(ext) || SOURCE_NAMES.has(n)) return 'source'
122  return 'other'
123}
124
125const CLASS_WORDS: Record<Exclude<Cls, 'regen'>, [string, string]> = {
126  data: ['data file', 'data files'], secret: ['likely secret file', 'likely secret files'],
127  source: ['source file', 'source files'], other: ['other file', 'other files'],
128}
129
130// ---- where the folder sits ----------------------------------------------------------------------
131
132const SYNC = /(^|\/)(Dropbox[^/]*|OneDrive[^/]*|Google ?Drive[^/]*|GoogleDrive[^/]*|iCloud ?Drive[^/]*|Mobile Documents|com~apple~CloudDocs|pCloud ?Drive|Box Sync|Library\/CloudStorage)(\/|$)/i
133const SYSTEM_TOP = /^\/(usr|etc|var|bin|sbin|lib|lib32|lib64|opt|boot|dev|proc|sys|run|root|home|Users|System|Library|Applications|Volumes|private|mnt|media|tmp)$/
134const HOME_FOLDERS = /^(Desktop|Documents|Downloads|Pictures|Music|Movies|Videos|Library|Applications|Public)$/i
135
136/** A hedged hint when the path looks like it lives in a synced folder, or in a folder iCloud may sync. */
137export function syncHint(path: string, home?: string): string | undefined {
138  const m = SYNC.exec(path)
139  if (m) {
140    const name = /^(Mobile Documents|com~apple~CloudDocs)$/i.test(m[2]!) ? 'iCloud Drive' : /^Library\/CloudStorage$/i.test(m[2]!) ? 'cloud storage' : m[2]!
141    return `Looks synced (${name}): deletes may reach your other devices; its trash may keep a copy (not checked)`
142  }
143  if (home && parentOf(path) === home && /^(Desktop|Documents)$/i.test(base(path)))
144    return 'Desktop and Documents may sync with iCloud (not checked): a delete could reach your other devices'
145  return undefined
146}
147
148export type Critical = { reason: string; skipScan: boolean }
149
150/** CRITICAL targets: root, home and its parents, top-level system folders, the usual home folders, the working directory or a parent of it. */
151export function criticalReason(dir: string, ctx: { home?: string; cwd?: string; runCwd?: string }): Critical | undefined {
152  const { home } = ctx
153  if (dir === '/') return { reason: 'the filesystem root', skipScan: true }
154  if (home && dir === home) return { reason: 'your home folder', skipScan: true }
155  if (home && inside(home, dir)) return { reason: 'a parent of your home folder', skipScan: true }
156  if (SYSTEM_TOP.test(dir)) return { reason: 'a top-level system folder', skipScan: true }
157  if (home && parentOf(dir) === home && HOME_FOLDERS.test(base(dir))) return { reason: `your ${base(dir)} folder`, skipScan: false }
158  for (const [where, label] of [[ctx.runCwd, 'the folder the command runs in'], [ctx.cwd, 'the current working directory']] as const) {
159    if (where === undefined || !inside(where, dir)) continue
160    return { reason: where === dir ? label : `a parent of ${label}`, skipScan: false }
161  }
162  return undefined
163}
164
165// ---- git view of one folder ---------------------------------------------------------------------
166
167type Bucket = 'pushed' | 'local' | 'changed' | 'untracked' | 'ignored' | 'outside'
168type PathSet = { exact: Set<string>; dirs: Set<string>; all: string[] } // dirs: collapsed directories, with a trailing '/'
169type GitView = {
170  root: string // the logical root: where git runs
171  isRoot: boolean // the folder IS the repository root (its .git goes with it)
172  noRemote: boolean
173  changed: PathSet; untracked: PathSet; ignored: PathSet
174  touched: PathSet // files in commits that no remote has
175  unpushedCommits: number
176  tracked: string[] // absolute paths
177}
178
179function pathSet(abs: string[]): PathSet {
180  return { exact: new Set(abs.filter(p => !p.endsWith('/'))), dirs: new Set(abs.filter(p => p.endsWith('/'))), all: abs }
181}
182// `path` is listed, or lies below a collapsed directory that is listed
183function covers(s: PathSet, path: string): boolean {
184  if (s.exact.has(path)) return true
185  for (let i = path.indexOf('/', 1); i > 0; i = path.indexOf('/', i + 1)) if (s.dirs.has(path.slice(0, i + 1))) return true
186  return s.dirs.has(path + '/')
187}
188const hasBelow = (s: PathSet, dir: string) => s.all.some(e => e.startsWith(dir + '/'))
189
190function bucketOf(path: string, isDir: boolean, g: GitView): Bucket {
191  if (covers(g.changed, path) || (isDir && hasBelow(g.changed, path))) return 'changed'
192  if (covers(g.untracked, path) || (isDir && hasBelow(g.untracked, path))) return 'untracked'
193  if (covers(g.ignored, path) || (isDir && hasBelow(g.ignored, path))) return 'ignored'
194  if (g.noRemote || covers(g.touched, path) || (isDir && hasBelow(g.touched, path))) return 'local'
195  return 'pushed'
196}
197
198type Probe = (argv: string[], cwd?: string) => Promise<RunResult | undefined>
199
200// null: the folder is not inside any repository
201async function gitView(run: Run, dir: string): Promise<GitView | null> {
202  const gitRun = (cwd: string, ...argv: string[]) => run([...GIT, ...argv], cwd)
203  const top = await gitRun(dir, 'rev-parse', '--show-toplevel', '--show-prefix')
204  if (top.exitCode !== 0) return null
205  const [toplevel = '', prefix = ''] = top.stdout.split('\n').map(l => l.replace(/\r$/, ''))
206  const rel = prefix.replace(/\/$/, '')
207  // git reports the real path; keep the path the user typed so that every path below matches the listing
208  const root = !rel ? dir : dir.endsWith('/' + rel) ? dir.slice(0, dir.length - rel.length - 1) : toplevel
209  const spec = rel ? ['--', rel] : []
210  const remotes = lines((await gitRun(root, 'remote')).stdout)
211  const status = await gitRun(root, 'status', '--porcelain=v1', '-z', '--ignored', ...spec)
212  if (status.exitCode !== 0) throw new Error(`git status failed: ${status.stderr.trim().split('\n')[0] || `exit ${status.exitCode}`}`)
213  const ents = statusEntries(status.stdout).map(e => ({ xy: e.xy, abs: `${root}/${e.path}` }))
214  const lost = (xy: string) => xy !== '??' && xy !== '!!' && !(xy === ' D' || xy === 'D ')
215  const files = await gitRun(root, 'ls-files', '-z', ...spec)
216  const unpushed = remotes.length
217    ? await gitRun(root, '-c', 'core.quotepath=off', 'log', '--name-only', '--format=%x01%h', '-n', '300', 'HEAD', '--not', '--remotes', ...spec)
218    : undefined
219  const touched: string[] = []
220  let commits = 0
221  for (const l of lines(unpushed?.exitCode === 0 ? unpushed.stdout : '')) {
222    if (l.startsWith('\x01')) commits++
223    else touched.push(`${root}/${l}`)
224  }
225  return {
226    root, isRoot: !rel, noRemote: remotes.length === 0,
227    changed: pathSet(ents.filter(e => lost(e.xy)).map(e => e.abs)),
228    untracked: pathSet(ents.filter(e => e.xy === '??').map(e => e.abs)),
229    ignored: pathSet(ents.filter(e => e.xy === '!!').map(e => e.abs)),
230    touched: pathSet(touched), unpushedCommits: commits,
231    tracked: files.stdout.split('\0').filter(Boolean).map(p => `${root}/${p}`),
232  }
233}
234
235// ---- the listing --------------------------------------------------------------------------------
236
237/** `( -type d ( -name n1 -o -name n2 ... ) -prune )`: build/cache directories and .git directories are not entered. */
238export function pruneExpr(): string[] {
239  const names = REGEN_NAMES.flatMap((n, k) => [...(k ? ['-o'] : []), '-name', n])
240  return ['(', '-type', 'd', '(', ...names, '-o', '-path', RENV_LIBRARY, '-o', '-name', '.git', ')', '-prune', ')']
241}
242
243type Entry = { path: string; dir: boolean; cls: Cls; bucket: Bucket; kb?: number }
244type Scan = {
245  dir: string; mode: Mode; git: GitView | null
246  entries: Entry[]; sampled: boolean; listed: number
247  gitDirs: string[] // .git directories found below the folder, other than its own
248  nested: { root: string; text: string; atStake: boolean }[]; nestedMore: number
249  totalKb?: number; files?: number; filesLowerBound?: boolean
250  newest?: string; newestCount?: number; newestMins?: number
251  links: number; crossesFs: boolean
252  ctxRegen?: string; partial: boolean; whole?: { text: string; atStake: boolean }
253}
254
255async function scanFolder(light: Run, heavy: Probe, state: { slow: boolean }, dir: string, ctx: FolderContext): Promise<Scan> {
256  const known = ctx.known?.get(dir)
257  const git = await gitView(light, dir)
258  // a build/ or dist/ that git tracks holds committed files: judge it by what is in it, not by its name
259  const ctxRegen = git && git.tracked.length > 0 ? undefined : regenContext(dir)
260  const expr = pruneExpr()
261  const walk = (...tail: string[]) => ['find', dir, '-mindepth', '1', ...expr, ...tail]
262
263  // build/cache and .git directories (not entered), then the other files, capped
264  const prunedOut = await heavy(['find', dir, '-mindepth', '1', ...expr.slice(1, -1), '-print'])
265  const prunedAll = lines(prunedOut?.stdout ?? '')
266  const ownGit = `${dir}/.git`
267  const gitDirs = prunedAll.filter(p => base(p) === '.git' && p !== ownGit)
268  const prunedRegen = prunedAll.filter(p => base(p) !== '.git')
269  const listRes = ctxRegen ? undefined : await heavy(walk('-o', '-type', 'f', '-print'))
270  const listed = lines(listRes?.stdout ?? '')
271  const sampled = listed.length > SAMPLE_CAP || !!listRes?.isStdoutTruncated
272  const files = listed.slice(0, SAMPLE_CAP)
273
274  const s: Scan = {
275    dir, mode: ctx.mode, git, entries: [], sampled, listed: listed.length, gitDirs, nested: [], nestedMore: 0,
276    totalKb: known?.kb, files: known?.files, filesLowerBound: known?.filesLowerBound,
277    links: 0, crossesFs: false, ctxRegen, partial: false,
278  }
279  if (!ctxRegen && git?.isRoot) s.whole = await wholeRepoFact(light, git.root, git.noRemote)
280
281  // nested repositories: their .git goes too; look at the first few
282  const roots = gitDirs.map(p => p.slice(0, -5))
283  s.nestedMore = Math.max(0, roots.length - NESTED_CAP)
284  for (const root of roots.slice(0, NESTED_CAP)) s.nested.push({ root, ...(await nestedFact(light, root)) })
285  const nestedIn = (p: string) => roots.some(r => p.startsWith(r + '/'))
286
287  const where = (p: string, isDir: boolean): Bucket => (git ? bucketOf(p, isDir, git) : 'outside')
288  for (const p of files)
289    if (!nestedIn(p)) s.entries.push({ path: p, dir: false, cls: ctxRegen ? 'regen' : classifyName(base(p)), bucket: where(p, false) })
290  for (const p of prunedRegen.filter(q => !nestedIn(q))) {
291    const tracked = git?.tracked.some(t => t.startsWith(p + '/')) ?? false
292    const cls: Cls = GENERIC.has(base(p)) && tracked ? 'source' : 'regen' // a committed dist/ or build/ is not just build output
293    s.entries.push({ path: p, dir: true, cls, bucket: where(p, true) })
294  }
295  if (ctxRegen) s.entries.push({ path: dir, dir: true, cls: 'regen', bucket: where(dir, true) })
296
297  // sizes: regenerable directories as a whole, data and secrets one by one, the folder in total
298  await sizes(heavy, s, ctxRegen !== undefined)
299  if (s.totalKb === undefined && !state.slow) s.totalKb = parseTotal(await heavy(['du', '-sk', dir]), dir)
300  if (!ctxRegen && !state.slow) await recency(heavy, s, walk)
301  if (!state.slow) s.links = lines((await heavy(walk('-o', '-type', 'l', '-print')))?.stdout ?? '').length
302  if (!state.slow && s.totalKb !== undefined) {
303    const one = parseTotal(await heavy(['du', '-skx', dir]), dir)
304    s.crossesFs = one !== undefined && s.totalKb - one > Math.max(64, s.totalKb * 0.01)
305  }
306  s.partial = state.slow
307  return s
308}
309
310const parseTotal = (r: RunResult | undefined, dir: string) => (r ? duMap(r.stdout).get(dir) : undefined)
311
312async function sizes(heavy: Probe, s: Scan, regenOnly: boolean): Promise<void> {
313  const dirs = s.entries.filter(e => e.dir && e.path !== s.dir)
314  if (dirs.length) {
315    const m = duMap((await heavy(['du', '-sk', ...dirs.slice(0, 40).map(e => e.path)]))?.stdout ?? '')
316    for (const e of dirs) e.kb = m.get(e.path)
317  }
318  if (regenOnly) return
319  const order: Cls[] = ['data', 'secret']
320  const picked = s.entries.filter(e => !e.dir && order.includes(e.cls)).slice(0, SIZE_CAP)
321  for (const part of chunks(picked, 60)) {
322    const m = duMap((await heavy(['du', '-k', ...part.map(e => e.path)]))?.stdout ?? '')
323    for (const e of part) e.kb = m.get(e.path)
324  }
325}
326
327async function recency(heavy: Probe, s: Scan, walk: (...tail: string[]) => string[]): Promise<void> {
328  for (const [mins, label] of BANDS) {
329    const r = await heavy(walk('-o', '-type', 'f', '-mmin', `-${mins}`, '-print'))
330    if (!r) return
331    const n = lines(r.stdout).length
332    if (n > 0) {
333      s.newest = label
334      s.newestCount = n
335      s.newestMins = mins
336      return
337    }
338  }
339  s.newest = 'not edited in 30 days'
340}
341
342async function nestedFact(light: Run, root: string): Promise<{ text: string; atStake: boolean }> {
343  try {
344    const remote = await light([...GIT, 'remote'], root)
345    const fact = await wholeRepoFact(light, root, lines(remote.stdout).length === 0)
346    const st = await light([...GIT, 'status', '--porcelain=v1', '-z'], root)
347    const dirty = statusEntries(st.stdout).length
348    return { text: dirty ? `${fact.text}, ${plural(dirty, 'uncommitted path')}` : fact.text, atStake: fact.atStake || dirty > 0 }
349  } catch (err) {
350    return { text: `${root}/  state unknown (${clip((err as Error).message, 60)})`, atStake: true }
351  }
352}
353
354// ---- the verdict --------------------------------------------------------------------------------
355
356type Verdict = { level: RiskLevel; recoverable: Recoverable; because: string; headline: string; onlyRepos: boolean; gitLine?: string; stake: string[] }
357
358function count<T>(a: T[], f: (x: T) => boolean) {
359  return a.filter(f).length
360}
361
362function judge(s: Scan, crit: Critical | undefined): Verdict {
363  const e = s.entries
364  const real = e.filter(x => x.cls !== 'regen')
365  const lostBy = (b: Bucket) => real.filter(x => x.bucket === b)
366  const changed = lostBy('changed'), untracked = lostBy('untracked'), outside = lostBy('outside'), ignored = lostBy('ignored'), local = lostBy('local')
367  const pushed = lostBy('pushed')
368  const whole = !!s.git?.isRoot
369  const nestedRisk = s.nested.some(n => n.atStake)
370  const hard = [...changed, ...untracked, ...outside]
371  const hardIgnored = ignored.filter(x => x.cls === 'data' || x.cls === 'secret')
372  const softIgnored = ignored.filter(x => x.cls !== 'data' && x.cls !== 'secret')
373  // committed but unpushed: restorable from .git while it stays, gone with it when the repository goes whole
374  const localLost = whole ? local : []
375  const machineOnly = whole ? [] : local
376  const noRemote = !!s.git?.noRemote
377  const onlyRepos = !s.git && s.entries.length === 0 && s.nested.length > 0 && s.nestedMore === 0 && !nestedRisk && !s.partial
378
379  let level: RiskLevel = 'low'
380  if (hard.length || hardIgnored.length || localLost.length || (whole && s.whole?.atStake) || nestedRisk) level = 'high'
381  else if (machineOnly.length && !noRemote) level = 'high'
382  else if (softIgnored.length || (s.sampled && !s.git)) level = 'medium'
383  if (level === 'medium' && s.newestMins !== undefined && s.newestMins <= 1440) level = 'high' // recent edits: active work
384  if (s.partial && level === 'low') level = 'unknown'
385  if (s.mode === 'modify') level = s.partial ? 'unknown' : real.some(x => x.cls === 'secret') || nestedRisk ? 'medium' : 'low'
386  if (crit) level = 'high'
387
388  const lost = hard.length + hardIgnored.length + softIgnored.length + localLost.length
389  const bits: string[] = []
390  if (s.git) {
391    const trackedOk = Math.max(0, s.git.tracked.length - count(s.git.tracked, t => covers(s.git!.changed, t) || covers(s.git!.touched, t)))
392    if (trackedOk) bits.push(`${plural(trackedOk, 'file')} pushed`)
393    if (s.git.unpushedCommits) bits.push(`${plural(s.git.unpushedCommits, 'commit')} not pushed`)
394    else if (noRemote && s.git.tracked.length) bits.push('no remote')
395    if (changed.length) bits.push(`${plural(changed.length, 'modified file')}`)
396    if (untracked.length) bits.push(`${plural(untracked.length, 'untracked file')}`)
397    if (ignored.length) bits.push(`${plural(ignored.length, 'ignored file')}`)
398  } else if (outside.length) bits.push(`${plural(outside.length, 'file')}${s.sampled ? ' in the sample' : ''}, no git history`)
399
400  let recoverable: Recoverable
401  let because: string
402  if (s.partial && !lost) (recoverable = 'unknown', because = 'the scan stopped early, so assume not')
403  else if (!lost && !machineOnly.length) {
404    recoverable = 'yes'
405    because = real.length || s.git?.tracked.length ? 'everything except build output is pushed (as of your last fetch)'
406      : s.entries.length ? 'only build output, which a rebuild recreates'
407      : onlyRepos ? 'every repository inside is fully pushed' : 'the folder is empty'
408  } else if (!lost) {
409    recoverable = noRemote ? 'yes' : 'partly'
410    because = noRemote ? 'git restore brings files back while .git stays; there is no remote copy' : `${plural(machineOnly.length, 'file')} committed but not pushed come back only from this .git`
411  } else {
412    recoverable = pushed.length || machineOnly.length ? 'partly' : 'no'
413    const gone = [changed.length && 'modified files', untracked.length && 'untracked files', hard.length - changed.length - untracked.length > 0 && 'files outside git', ignored.length && 'ignored files', localLost.length && 'unpushed commits'].filter(Boolean)
414    because = `${gone.join(', ')} cannot be restored${pushed.length ? '; pushed files can' : ''}`
415  }
416  if (recoverable === 'yes' && ((whole && s.whole?.atStake) || nestedRisk)) {
417    recoverable = 'partly'
418    because = 'commits on no remote, stashes or worktrees exist only in the .git that goes too'
419  }
420  if (s.mode === 'modify') {
421    recoverable = s.partial ? 'unknown' : 'partly'
422    because = 'contents stay; old permissions and owners are not recorded'
423  }
424  if (crit && s.mode !== 'modify') {
425    recoverable = 'no'
426    because = `${crit.reason} is targeted; assume everything is gone (backups such as Time Machine are not checked)`
427  }
428
429  const stake = real
430    .filter(x => (x.bucket === 'changed' || x.bucket === 'untracked' || x.bucket === 'outside' || x.bucket === 'ignored' || (whole && x.bucket === 'local')) && (x.cls === 'data' || x.cls === 'secret' || x.bucket === 'changed'))
431    .sort((a, b) => (b.kb ?? 0) - (a.kb ?? 0))
432    .map(x => `${x.path.startsWith(s.dir + '/') ? x.path.slice(s.dir.length + 1) : x.path}${x.kb !== undefined ? ` (${kbText(x.kb)})` : ''}`)
433
434  const headline = crit
435    ? `${cap1(crit.reason)} is targeted`
436    : level === 'low'
437      ? s.entries.length && !real.length ? 'build output only (regenerable by name)' : onlyRepos ? 'only whole repositories, all fully pushed' : 'fully pushed (as of your last fetch)'
438      : `${recoverable === 'no' ? 'not recoverable' : recoverable === 'partly' ? 'only partly recoverable' : 'see the folder details'}: ${because}`
439  return { level, recoverable, because, headline, onlyRepos, gitLine: bits.length ? bits.join(' · ') : undefined, stake }
440}
441
442// ---- words --------------------------------------------------------------------------------------
443
444function render(s: Scan, v: Verdict, crit: Critical | undefined, ctx: FolderContext): FolderRisk {
445  const rows: Row[] = []
446  const name = (p: string) => (p.startsWith(s.dir + '/') ? p.slice(s.dir.length + 1) : p)
447  if (crit) rows.push(['Target', cap1(crit.reason)])
448
449  // where it sits
450  if (s.git?.isRoot) rows.push(['Where', 'Repository root · its history goes too'])
451  else if (s.git) rows.push(['Where', `Inside repository ${clip(s.git.root, 60)}`])
452  else rows.push(['Where', 'Outside git · no history, no remote'])
453  if (s.git?.isRoot && s.whole?.atStake) rows.push(['Local only', s.whole.text.replace(/^.*?\/ {2}/, '')])
454  if (s.nested.length)
455    rows.push(['Includes', `${plural(s.nested.length + s.nestedMore, 'repository', 'repositories')}, .git too: ${s.nested.map(n => clip(n.text.replace(s.dir + '/', ''), 80)).join('; ')}${s.nestedMore ? `; +${s.nestedMore} more` : ''}`])
456
457  // what kind of content
458  const bits: string[] = []
459  if (s.ctxRegen) bits.push(`all build output (${s.ctxRegen})`)
460  for (const cls of ['data', 'secret', 'source', 'other'] as const) {
461    const m = s.entries.filter(e => e.cls === cls && !e.dir)
462    if (!m.length) continue
463    const kb = sum(m.map(e => e.kb ?? 0))
464    const sized = m.some(e => e.kb !== undefined) && (cls === 'data' || cls === 'secret')
465    bits.push(`${plural(m.length, CLASS_WORDS[cls][0], CLASS_WORDS[cls][1])}${sized ? ` (${kbText(kb)}${m.some(e => e.kb === undefined) ? '+' : ''})` : ''}`)
466  }
467  const regen = s.entries.filter(e => e.cls === 'regen' && e.dir && e.path !== s.dir).sort((a, b) => (b.kb ?? 0) - (a.kb ?? 0))
468  if (regen.length)
469    bits.push(`build output: ${regen.slice(0, 3).map(e => `${name(e.path)}/${e.kb !== undefined ? ` ${kbText(e.kb)}` : ''}`).join(', ')}${regen.length > 3 ? `, +${regen.length - 3} more` : ''}`)
470  const committedGeneric = s.entries.filter(e => e.dir && e.cls === 'source')
471  if (committedGeneric.length) bits.push(`${committedGeneric.map(e => name(e.path) + '/').join(', ')} hold tracked files`)
472  if (ctx.note) bits.push(ctx.note)
473  if (s.sampled) bits.push(`sampled: first ${fmt(SAMPLE_CAP)} of ${fmt(s.listed)}${s.listed > SAMPLE_CAP ? '' : '+'} files`)
474  rows.push(['Contains', bits.length ? bits.join(' · ') : s.nested.length ? 'only the repositories above' : 'nothing'])
475
476  // size and recency
477  const size = [s.totalKb !== undefined ? kbText(s.totalKb) : undefined, s.files !== undefined ? `${fmt(s.files)}${s.filesLowerBound ? '+' : ''} ${s.files === 1 && !s.filesLowerBound ? 'file' : 'files'}` : undefined].filter(Boolean) as string[]
478  if (s.newest) size.push(s.newestMins === undefined ? s.newest : `last edit ${s.newest}`)
479  if (s.newestMins !== undefined && s.newestMins <= 1440) size.push('likely active work')
480  if (size.length) rows.push(['Size', size.join(' · ')])
481
482  if (v.gitLine && s.mode !== 'modify') rows.push(['Git', v.gitLine])
483  if (v.stake.length) rows.push(['At stake', `${v.stake.slice(0, 3).map(x => clip(x, 50)).join(', ')}${v.stake.length > 3 ? `, +${v.stake.length - 3} more` : ''}`])
484
485  const hints: string[] = []
486  if (s.partial) hints.push('Scan stopped early (slow disk or huge folder): figures are partial')
487  const sync = syncHint(s.dir, ctx.home)
488  if (sync) hints.push(sync)
489  if (s.links > 0) hints.push(`${plural(s.links, 'symlink')} inside: only the links are removed, not their targets`)
490  if (s.crossesFs) hints.push('Includes another filesystem (a mounted drive?): rm -r follows it unless run with --one-file-system')
491  for (const h of hints.slice(0, 2)) rows.push(['Note', clip(h, 140)])
492  rows.push(['Recoverable', `${cap1(v.recoverable)} · ${v.because}`])
493
494  return {
495    path: s.dir, title: `${dirLabel(s.dir, ctx)} · ${LEVEL_WORD[v.level]}`, level: v.level, rows, headline: v.headline,
496    regenerable: s.entries.length > 0 && s.entries.every(e => e.cls === 'regen'), complete: !s.partial, onlyRepos: v.onlyRepos,
497  }
498}
499const name2 = (p: string, root: string) => p.slice(root.length + 1)
500
501// ---- symbolic links and unscanned folders -------------------------------------------------------
502
503async function symlinkRisk(light: Run, dir: string, ctx: FolderContext, crit: Critical | undefined): Promise<FolderRisk> {
504  const toDir = lines((await light(['find', '-L', dir, '-maxdepth', '0', '-type', 'd', '-print'])).stdout).length > 0
505  const act: Record<Mode, string> = {
506    delete: 'rm removes only the link. A trailing slash (link/) can reach the target, so check how it is written',
507    modify: 'chmod -R changes the target of a link named on the command line; chown may change the link itself',
508    overwrite: 'cp or mv -f replaces the link or writes through it; the target was not inspected',
509  }
510  const level: RiskLevel = ctx.mode === 'delete' && !crit ? 'low' : 'medium'
511  const rows: Row[] = []
512  if (crit) rows.push(['Target', cap1(crit.reason)])
513  rows.push(['What', `Symbolic link ${toDir ? 'to a folder' : 'to a file (or broken)'} · the target was not inspected`])
514  rows.push(['Effect', act[ctx.mode]])
515  const sync = syncHint(dir, ctx.home)
516  if (sync) rows.push(['Note', sync])
517  rows.push(['Recoverable', ctx.mode === 'delete' ? 'Yes · only the link goes; the target keeps its content' : 'Unknown · the target was not inspected'])
518  return { path: dir, title: `${dirLabel(dir, ctx)} (symlink) · ${LEVEL_WORD[level]}`, level, rows, headline: ctx.mode === 'delete' ? 'a symlink: only the link goes, not its target' : 'a symlink: its target was not inspected', regenerable: false, complete: ctx.mode === 'delete' }
519}
520
521const dirLabel = (dir: string, ctx: FolderContext) => (dir === '/' ? '/' : `${ctx.runCwd && dir.startsWith(ctx.runCwd + '/') ? name2(dir, ctx.runCwd) : dir}/`)
522
523function unscanned(dir: string, crit: Critical | undefined, ctx: FolderContext, why: string): FolderRisk {
524  const level: RiskLevel = crit ? 'high' : 'unknown'
525  const rows: Row[] = []
526  if (crit) rows.push(['Target', cap1(crit.reason)])
527  rows.push(['Scan', `Skipped · ${why}`])
528  const hint = syncHint(dir, ctx.home)
529  if (hint) rows.push(['Note', hint])
530  if (crit && ctx.mode === 'modify')
531    rows.push(['Recoverable', `Partly · contents stay, but old permissions and owners are not recorded; changing them across ${crit.reason} can break ssh keys and system files`])
532  else rows.push(['Recoverable', crit ? 'No · assume everything is gone (backups such as Time Machine are not checked)' : 'Unknown · nothing was inspected, so assume not'])
533  return {
534    path: dir, title: `${dirLabel(dir, ctx)} · ${LEVEL_WORD[level]}`, level, rows,
535    headline: crit ? `${cap1(crit.reason)} is targeted` : why, regenerable: false, complete: false,
536  }
537}
538
539// ---- entry point --------------------------------------------------------------------------------
540
541/** Folder risk for up to `maxFolders` directories (CRITICAL ones first). Never throws. */
542export async function assessFolders(run: Run, dirs: string[], ctx: FolderContext): Promise<{ folders: FolderRisk[]; skipped: number }> {
543  const ordered = [...new Set(dirs)].map(d => ({ d, c: criticalReason(d, ctx) })).sort((a, b) => Number(!!b.c) - Number(!!a.c))
544  const max = ctx.maxFolders ?? 3
545  const folders: FolderRisk[] = []
546  for (const { d, c } of ordered.slice(0, max)) folders.push(await assessOne(run, d, c, ctx))
547  return { folders, skipped: Math.max(0, ordered.length - max) }
548}
549
550async function assessOne(run: Run, dir: string, crit: Critical | undefined, ctx: FolderContext): Promise<FolderRisk> {
551  const state = { slow: false, failed: undefined as string | undefined }
552  // light probes (git, type tests) throw to the caller; heavy ones (tree walks) stop the scan at the first timeout
553  const light: Run = (argv, cwd) => run(argv, cwd, { timeoutMs: FOLDER_PROBE_MS })
554  const heavy: Probe = async (argv, cwd) => {
555    if (state.slow) return undefined
556    try {
557      return await run(argv, cwd, { timeoutMs: FOLDER_PROBE_MS })
558    } catch (err) {
559      state.slow = true
560      state.failed = (err as Error).message || 'probe failed'
561      return undefined
562    }
563  }
564  try {
565    const link = lines((await light(['find', dir, '-maxdepth', '0', '-type', 'l', '-print'])).stdout).length > 0
566    if (link) return await symlinkRisk(light, dir, ctx, crit)
567    if (crit?.skipScan) return unscanned(dir, crit, ctx, 'too broad to inspect')
568    const s = await scanFolder(light, heavy, state, dir, ctx)
569    return render(s, judge(s, crit), crit, ctx)
570  } catch (err) {
571    return unscanned(dir, crit, ctx, `could not scan (${clip((err as Error).message || 'probe failed', 100)})`)
572  }
573}
574
hooks/recover.ts 245 lines
1// The recoverability check: for paths that are about to be deleted, overwritten or emptied,
2// say what can NOT be got back. READ-ONLY and argv-only like the rest of the measurer.
3//
4// Questions answered per path, using git where the path is inside a repository:
5//   uncommitted change   tracked, but modified/staged: the edit exists only in the working tree
6//   untracked            never committed: no history anywhere
7//   ignored              git-ignored: data, builds, .Rdata ... not in history either
8//   unpushed             committed, but on no remote branch: exists only on this machine
9//   pushed and clean     recoverable (git restore, or clone) -- "as of your last fetch"
10//   not in a repository  no history, no remote: gone for good
11// and, for a repository that is deleted whole (its .git goes too): the local-only commits,
12// stashes and uncommitted work that go with it.
13//
14// Contract: never throws, never says "safe" when a probe failed. A failed probe yields level
15// 'unknown' with a note, and the command is held anyway (the caller already holds it).
16import type { Group, Run } from './measure'
17
18export const PUSHED_NOTE = 'Pushed means as of your last fetch'
19export type RiskLevel = 'high' | 'medium' | 'low' | 'unknown'
20export type Risk = { level: RiskLevel; headline: string }
21export type Assessment = { risk: Risk; groups: Group[]; notes: string[] }
22
23type Cfg = { maxListed: number }
24type Tally = { changed: string[]; untracked: string[]; ignored: string[]; unpushed: string[]; trackedClean: number }
25
26export const GIT = ['git', '--no-optional-locks', '--no-pager', '--literal-pathspecs']
27const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
28const n = (k: number) => k.toLocaleString('en-US')
29const plural = (k: number, w: string, many = w + 's') => `${n(k)} ${k === 1 ? w : many}`
30const dirname = (p: string) => (p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/')
31const inside = (child: string, parent: string) => child === parent || child.startsWith(parent === '/' ? '/' : parent + '/')
32const chunks = <T>(a: T[], size: number) => Array.from({ length: Math.ceil(a.length / size) }, (_, k) => a.slice(k * size, (k + 1) * size))
33const cap = (title: string, rows: string[], max: number, flags: { subset?: boolean; extra?: boolean } = {}): Group => ({ title, rows: rows.slice(0, max), more: Math.max(0, rows.length - max), ...flags })
34
35const LABELS: Record<string, string> = { ' M': 'modified', 'M ': 'staged', MM: 'staged+modified', 'A ': 'new (staged)', AM: 'new+modified', ' T': 'type changed', 'R ': 'renamed (staged)', UU: 'CONFLICT' }
36const label = (xy: string) => LABELS[xy] ?? `changed (${xy.trim() || xy})`
37
38/** `git status --porcelain=v1 -z` as raw entries: the two status letters and the path (repository-root relative). */
39export function statusEntries(out: string): { xy: string; path: string }[] {
40  const res: { xy: string; path: string }[] = []
41  const toks = out.split('\0')
42  for (let i = 0; i < toks.length; i++) {
43    const t = toks[i]!
44    if (t.length < 4) continue
45    const xy = t.slice(0, 2)
46    if (xy[0] === 'R' || xy[0] === 'C') i++ // the next token is the rename's original path
47    res.push({ xy, path: t.slice(3) })
48  }
49  return res
50}
51
52/** `git status --porcelain=v1 -z` into change classes. Paths are relative to the repository root. */
53export function parseStatusZ(out: string): { changed: string[]; untracked: string[]; ignored: string[] } {
54  const res = { changed: [] as string[], untracked: [] as string[], ignored: [] as string[] }
55  for (const { xy, path } of statusEntries(out)) {
56    if (xy === '??') res.untracked.push(path)
57    else if (xy === '!!') res.ignored.push(path)
58    else if (xy[1] === 'D' && xy[0] === ' ') continue // already gone from the working tree
59    else if (xy[0] === 'D' && xy[1] === ' ') continue // staged removal, nothing left to lose
60    else res.changed.push(`${label(xy)}  ${path}`)
61  }
62  return res
63}
64
65async function git(run: Run, cwd: string, ...argv: string[]) {
66  return run([...GIT, ...argv], cwd)
67}
68
69async function dirsAmong(run: Run, paths: string[]): Promise<Set<string>> {
70  const found = new Set<string>()
71  for (const part of chunks(paths, 60))
72    for (const l of lines((await run(['find', ...part, '-maxdepth', '0', '-type', 'd', '-print'])).stdout)) found.add(l)
73  return found
74}
75
76async function countFiles(run: Run, dir: string): Promise<number> {
77  const res = await run(['find', dir, '!', '-type', 'd', '-print'])
78  return lines(res.stdout).length
79}
80
81/** Repositories wholly inside a deleted directory (their .git goes too). */
82async function nestedRepos(run: Run, dirs: string[]): Promise<string[]> {
83  const roots = new Set<string>()
84  for (const d of dirs.slice(0, 20)) {
85    const res = await run(['find', d, '-maxdepth', '6', '-name', '.git', '-prune', '-print'])
86    for (const l of lines(res.stdout)) if (l.endsWith('/.git')) roots.add(l.slice(0, -5))
87  }
88  return [...roots].slice(0, 20)
89}
90
91export async function assessLoss(run: Run, paths: string[], cfg: Cfg): Promise<Assessment> {
92  const out: Assessment = { risk: { level: 'unknown', headline: '' }, groups: [], notes: [] }
93  try {
94    return await assess(run, [...new Set(paths)].slice(0, 500), cfg)
95  } catch (err) {
96    out.risk.headline = 'could not check what git could restore: assume nothing is recoverable'
97    out.notes.push(`risk check failed: ${(err as Error).message || 'probe failed'}`)
98    return out
99  }
100}
101
102async function assess(run: Run, paths: string[], cfg: Cfg): Promise<Assessment> {
103  const notes: string[] = []
104  const dirs = await dirsAmong(run, paths)
105  const dirList = [...dirs]
106
107  // 1. which repository (if any) holds each path; repos that are deleted whole count as scopes of their own
108  const scopes = new Map<string, string[]>() // repo root -> paths inside it
109  const outside: string[] = []
110  const rootOf = new Map<string, string | null>()
111  for (const p of paths) {
112    const probe = dirs.has(p) ? p : dirname(p)
113    if (!rootOf.has(probe)) {
114      const res = await git(run, probe, 'rev-parse', '--show-toplevel')
115      rootOf.set(probe, res.exitCode === 0 ? res.stdout.trim() : null)
116    }
117    const root = rootOf.get(probe)
118    if (root) scopes.set(root, [...(scopes.get(root) ?? []), p])
119    else outside.push(p)
120  }
121  const whole = new Set<string>()
122  for (const root of scopes.keys()) if (dirs.has(root) && paths.includes(root)) whole.add(root)
123  for (const r of await nestedRepos(run, dirList)) {
124    whole.add(r)
125    if (!scopes.has(r)) scopes.set(r, [r])
126  }
127
128  // 2. per repository: uncommitted, untracked, ignored, unpushed
129  const total: Tally = { changed: [], untracked: [], ignored: [], unpushed: [], trackedClean: 0 }
130  const wholeFacts: string[] = []
131  let wholeAtStake = 0
132  let noRemote = false
133  for (const [root, inRepo] of scopes) {
134    const remotes = lines((await git(run, root, 'remote')).stdout)
135    const t = await tally(run, root, inRepo, cfg, notes, remotes.length > 0)
136    total.changed.push(...t.changed)
137    total.untracked.push(...t.untracked)
138    total.ignored.push(...t.ignored)
139    total.unpushed.push(...t.unpushed)
140    total.trackedClean += t.trackedClean
141    if (remotes.length === 0) noRemote = true
142    if (whole.has(root)) {
143      const fact = await wholeRepoFact(run, root, remotes.length === 0)
144      wholeFacts.push(fact.text)
145      if (fact.atStake) wholeAtStake++
146    }
147  }
148
149  // 3. words
150  const groups: Group[] = []
151  const max = cfg.maxListed
152  if (wholeFacts.length) groups.push(cap('Whole repositories inside (their history goes too)', wholeFacts, max, { extra: true }))
153  if (total.changed.length) groups.push(cap('Uncommitted changes (gone for good)', total.changed, max))
154  if (total.untracked.length) groups.push(cap('Untracked, never committed (gone for good)', total.untracked, max, { subset: true }))
155  if (total.ignored.length) groups.push(cap('Git-ignored (gone unless you can regenerate them)', total.ignored, max, { subset: true }))
156  if (total.unpushed.length)
157    groups.push(cap('Committed, not pushed (only in this .git)', total.unpushed, max))
158  if (outside.length) groups.push(cap('Outside git, no history or remote (gone for good)', outside, max, { subset: true }))
159
160  const lostForever = total.changed.length + total.untracked.length + total.ignored.length + outside.length
161  const bits: string[] = []
162  if (total.changed.length) bits.push(`${plural(total.changed.length, 'file')} with uncommitted changes`)
163  if (total.untracked.length) bits.push(`${plural(total.untracked.length, 'untracked path')}`)
164  if (total.ignored.length) bits.push(`${plural(total.ignored.length, 'git-ignored path')}`)
165  if (outside.length) bits.push(`${plural(outside.length, 'path')} outside any repository`)
166  if (total.unpushed.length) bits.push(`${plural(total.unpushed.length, 'unpushed commit')} touching these paths`)
167  if (wholeFacts.length) bits.push(`${plural(wholeFacts.length, 'whole repository', 'whole repositories')}`)
168
169  let risk: Risk
170  // a repository deleted whole is only a hard loss when something exists nowhere else: commits on no remote, stashes,
171  // linked worktrees, or no remote at all. A clean, fully pushed one comes back with a fresh clone.
172  const hardLoss = total.changed.length + total.untracked.length + outside.length + total.unpushed.length + wholeAtStake
173  if (hardLoss > 0) risk = { level: 'high', headline: `${bits.join(', ')}: none of it is on a remote` }
174  else if (total.ignored.length > 0) risk = { level: 'medium', headline: `${bits.join(', ')}: not in version control` }
175  else if (scopes.size > 0)
176    risk = {
177      level: 'low',
178      headline: noRemote
179        ? `${plural(total.trackedClean, 'tracked file')} committed locally (no remote): git restore works while .git stays`
180        : `${plural(total.trackedClean, 'tracked file')} pushed (as of your last fetch): git restore or a fresh clone brings them back${wholeFacts.length ? ', no local-only commits or stashes' : ''}`,
181    }
182  else risk = { level: 'unknown', headline: 'nothing to assess' }
183
184  if (noRemote && scopes.size > 0) notes.push('No remote: every commit exists only on this machine')
185  if (lostForever === 0 && total.unpushed.length === 0 && wholeFacts.length === 0) notes.push(PUSHED_NOTE)
186  return { risk, groups, notes }
187}
188
189async function tally(run: Run, root: string, inRepo: string[], cfg: Cfg, notes: string[], hasRemote: boolean): Promise<Tally> {
190  const t: Tally = { changed: [], untracked: [], ignored: [], unpushed: [], trackedClean: 0 }
191  const specs = inRepo.slice(0, 200)
192  const st = await git(run, root, 'status', '--porcelain=v1', '-z', '--ignored', '--', ...specs)
193  if (st.exitCode !== 0) throw new Error(`git status failed in ${root}: ${st.stderr.trim().split('\n')[0] || `exit ${st.exitCode}`}`)
194  if (st.isStdoutTruncated) notes.push(`${root}: git status output was truncated, counts are lower bounds`)
195  const parsed = parseStatusZ(st.stdout)
196  t.changed = parsed.changed
197  // a collapsed directory ("build/") stands for many files: say how many
198  const expand = async (entries: string[]) => {
199    const rows: string[] = []
200    let looked = 0
201    for (const e of entries) {
202      if (e.endsWith('/') && looked < 10) {
203        looked++
204        const count = await countFiles(run, `${root}/${e.replace(/\/$/, '')}`)
205        rows.push(`${e}  (${plural(count, 'file')})`)
206      } else rows.push(e)
207    }
208    return rows
209  }
210  t.untracked = await expand(parsed.untracked)
211  t.ignored = await expand(parsed.ignored)
212
213  const ls = await git(run, root, 'ls-files', '-z', '--', ...specs)
214  const tracked = ls.stdout.split('\0').filter(Boolean).length
215  t.trackedClean = Math.max(0, tracked - parsed.changed.length)
216
217  // without any remote every commit is "unpushed": that says nothing about these paths, so it is a note instead
218  if (hasRemote) {
219    const unpushed = await git(run, root, 'log', '--format=%h %s', '-n', String(cfg.maxListed + 1), 'HEAD', '--not', '--remotes', '--', ...specs)
220    if (unpushed.exitCode === 0) t.unpushed = lines(unpushed.stdout)
221  }
222  // an empty repository (no HEAD yet) makes `git log HEAD` fail: that is not an error worth stopping for
223  return t
224}
225
226/** What a whole repository would take with it. `atStake` is true when any of it exists nowhere else. */
227export async function wholeRepoFact(run: Run, root: string, noRemote: boolean): Promise<{ text: string; atStake: boolean }> {
228  const local = await git(run, root, 'rev-list', '--count', '--branches', '--tags', '--not', '--remotes')
229  const stash = await git(run, root, 'stash', 'list')
230  const wt = await git(run, root, 'worktree', 'list')
231  const localCommits = local.exitCode === 0 ? Number(local.stdout.trim()) || 0 : undefined
232  const stashes = lines(stash.stdout).length
233  const worktrees = lines(wt.stdout).length - 1
234  const bits = [
235    localCommits !== undefined ? `${plural(localCommits, 'commit')} on no remote` : 'local-only commits unknown',
236    `${plural(stashes, 'stash', 'stashes')}`,
237  ]
238  if (worktrees > 0) bits.push(`${plural(worktrees, 'linked worktree')}`)
239  if (noRemote) bits.push('no remote')
240  return {
241    text: `${root}/  ${bits.join(', ')}`,
242    atStake: localCommits === undefined || localCommits > 0 || stashes > 0 || worktrees > 0 || noRemote,
243  }
244}
245
types/index.d.ts 12 lines
1/** The runtime sensitivity: off = never ask, low = ask for everything, medium = from medium up, high = only high. */
2export type BlastLevel = 'off' | 'low' | 'medium' | 'high'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'command-guard': {
7      /** The persisted runtime level (`$.store` 'level'), mirrored here so other mods, such as the moddeck side bar, can read it. Overrides the askFrom setting. */
8      level: BlastLevel
9    }
10  }
11}
12