SLOPSHOPPER

worktree-guard

Guards a shared worktree: stops `git add -A` from staging files this session did not touch, previews destructive git commands, and points at the process…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-02thieung/claude-mods/worktree-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktree-guard
│ ┃ worktree-guard ✕ › fix the failing auth test and add an audit log call │ ┃ Không có lệnh nào đang chờ. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /guard-files │ ⎿ worktree-guard: worktree-guard: chưa có lệnh git nào bị giữ tron │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · worktree-guard
Không có lệnh nào đang chờ.
README

worktree-guard

A Claude Code mod for worktrees that several sessions share. It keeps one session from sweeping another session's work into a commit, and from throwing it away.

What it does

  • Stage guard. At session start it records which files are already dirty. It also records every file the session writes through Edit, Write or NotebookEdit. When the model runs git add -A, git add ., git add -u or git commit -a, the guard sorts the files that command would stage:
  • foreign: dirty before the session began, and never edited by it. If there are any, the guard asks you, and blocks the command unless you allow it. The block message suggests git add <this session's files>.
  • unknown: became dirty during the session, but not through Edit or Write (codegen, formatters, sed). These only raise a toast.
  • Destructive preview. git stash, git checkout -- …, git checkout ., git restore, git reset --hard, git switch --discard-changes and git clean are dry-run first. You see which files they would touch, then choose to allow or block.
  • Display. A pane shows one summary line and the files grouped by directory, at most 8 groups, with a Xem hết button that expands the full list. On a terminal too narrow for a pane (under 144 columns), a single line above the prompt stands in for it. /guard-files prints the full list of the last guarded command.
  • Ports. When a command fails with EADDRINUSE, a toast names the PID and working directory holding the port. When the session ends, the guard lists processes still listening from inside the worktree.

Limits

This is a safety net, not access control. Commands it cannot see go through: shell aliases, script files, commands assembled at run time, and git started by another program. It also does not guard these:

  • a git commit without -a, which commits whatever is already staged, possibly by another session;
  • hunks that another session added to a file this session also edited;
  • git stash drop and git stash clear.

When nobody can answer the question (a headless claude -p run, or a dismissed dialog), the guard blocks. If its own check fails, a guarded command is blocked too.

What it runs, reads and sends

Nothing leaves your machine. The mod makes no network calls; what it shows stays in your terminal or desktop app as a pane, a band, a toast or a transcript line, and the deny text goes to the model as the tool's error.

  • Programs it starts, each as a fixed argument list with no shell:
  • git -c core.quotePath=false with rev-parse --show-toplevel --show-prefix, status --porcelain=v1 -z --untracked-files=all, diff --name-only -z [<commit>] [-- <paths>] and clean -n <the command's own flags and paths>. These are read-only dry runs that tell which files a command would touch.
  • lsof -nP with -iTCP:<port> -sTCP:LISTEN, -iTCP -sTCP:LISTEN and -a -d cwd -p <pids>. They find the process holding a busy port and the listeners left inside the worktree.
  • What it reads from the conversation: the command text of each Bash call, to recognize the git commands above, and that call's output, to spot EADDRINUSE. It also reads the file path of each Edit, Write and NotebookEdit call, to know which files this session wrote.
  • Hooks that can change a call: the Bash tool.call hook can refuse a guarded git command after asking you; it never rewrites a command. The other tool.call hook only records file paths. session.start registers the /guard-files command, and the command.run hook answers it with the full file list.
  • What it keeps: the session's baseline of dirty files, the files it wrote and the last guarded command, in the session's plugin state. Nothing is written to disk.

Install

claude plugin marketplace add thieung/claude-mods
claude plugin install worktree-guard@thieung-mods
Source 5 files
hooks/register.tsx 333 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Review } from '../types'
5import { busyPort, findGuarded, type GuardedCommand } from './lib/commands'
6import { classify, expandDirs, groupByDir, normalize, parseStatus, shellQuote, toRepoPath, underPathspecs } from './lib/git-status'
7import { lsofRecords, type Listener } from './lib/ports'
8
9const PLUGIN = 'worktree-guard'
10const PANE = 'worktree-guard'
11/** Groups shown before the rest fold into one "and N more" row. */
12const MAX_GROUPS = 8
13const ALLOW = 'Vẫn chạy'
14const DENY = 'Chặn lệnh'
15/** Marks the state after a /clear: the next session.start adopts the new id and keeps the baseline. */
16const KEEP = '*keep*'
17const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit']
18
19const sessionId = atom({ plugin: 'worktree-guard', key: 'sessionId' } as const, null)
20const root = atom({ plugin: 'worktree-guard', key: 'root' } as const, null)
21const baseline = atom({ plugin: 'worktree-guard', key: 'baseline' } as const, [])
22const own = atom({ plugin: 'worktree-guard', key: 'own' } as const, [])
23const review = atom({ plugin: 'worktree-guard', key: 'review' } as const, null)
24const isExpanded = atom({ plugin: 'worktree-guard', key: 'isExpanded' } as const, false)
25
26/** Runs git with paths printed raw (no octal quoting of non-ASCII names); throws when git fails or its output was cut. */
27async function git($: EngineInterface, args: string[], cwd: string): Promise<string> {
28  const ran = await $.process.run(['git', '-c', 'core.quotePath=false', ...args], { cwd })
29  if (ran.exitCode !== 0) throw new Error(`git ${args[0]} exited ${ran.exitCode}: ${ran.stderr.trim().slice(0, 200)}`)
30  if (ran.isStdoutTruncated) throw new Error(`git ${args[0]} output was cut`)
31  return ran.stdout
32}
33
34const statusOf = async ($: EngineInterface, top: string) =>
35  parseStatus(await git($, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], top))
36
37const nulList = (out: string) => out.split('\0').filter(Boolean)
38
39/** The repository a directory belongs to and its place in it; null when it is not inside one. */
40async function locate($: EngineInterface, dir: string): Promise<{ top: string; prefix: string } | null> {
41  const out = await git($, ['rev-parse', '--show-toplevel', '--show-prefix'], dir).catch(() => null)
42  if (out === null) return null
43  const [top = '', prefix = ''] = out.split('\n')
44  return top ? { top, prefix } : null
45}
46
47/** The repo-relative files one guarded command would sweep in, shelve or throw away. */
48async function touchedBy($: EngineInterface, top: string, prefix: string, dir: string, cmd: GuardedCommand): Promise<string[]> {
49  switch (cmd.kind) {
50    case 'stage': {
51      const entries = (await statusOf($, top)).filter(x => !cmd.isTrackedOnly || !x.isUntracked)
52      return underPathspecs(entries.map(x => x.path), prefix, cmd.paths)
53    }
54    case 'stash':
55      return (await statusOf($, top)).filter(x => cmd.withUntracked || !x.isUntracked).map(x => x.path)
56    case 'discard': {
57      const spec = cmd.paths.length ? ['--', ...cmd.paths] : []
58      return nulList(await git($, ['diff', '--name-only', '-z', ...(cmd.source ? [cmd.source] : []), ...spec], dir))
59    }
60    case 'clean': {
61      const removed = (await git($, ['clean', '-n', ...cmd.args], dir))
62        .split('\n')
63        .map(line => line.match(/^Would remove (.+)$/)?.[1])
64        .filter((path): path is string => path !== undefined)
65        .map(path => {
66          const rel = normalize(`${prefix}${path}`) ?? path
67          return path.endsWith('/') ? `${rel}/` : rel
68        })
69      const untracked = (await statusOf($, top)).filter(x => x.isUntracked).map(x => x.path)
70      return expandDirs(removed, untracked)
71    }
72  }
73}
74
75/** One question at a time: parallel tool calls each wait for the previous decision. */
76let queue: Promise<unknown> = Promise.resolve()
77function serially<T>(work: () => Promise<T>): Promise<T> {
78  const run = queue.then(work, work)
79  queue = run.catch(() => undefined)
80  return run
81}
82
83type Decision = 'allow' | 'deny' | 'none'
84
85/** Shows the review in the pane (the band stands in while the pane has no seat) and asks the person. */
86function decide($: EngineInterface, held: Review, question: string): Promise<Decision> {
87  return serially(async () => {
88    await update($, isExpanded, () => false)
89    await update($, review, () => held)
90    try {
91      await $.ui.open({ id: PANE, title: 'Worktree guard' }).catch(() => undefined)
92      const answer = await $.ui.ask(question, { header: 'Worktree', options: [DENY, ALLOW] }).catch(() => null)
93      return answer === ALLOW ? 'allow' : answer === null ? 'none' : 'deny'
94    } finally {
95      await update($, review, now => (now ? { ...now, isPending: false } : now)).catch(() => undefined)
96      await $.ui.close({ id: PANE }).catch(() => undefined)
97    }
98  })
99}
100
101function summary(held: Review): string {
102  const others = held.foreign.length + held.unknown.length
103  if (held.kind === 'stage') {
104    return `⚠ ${held.foreign.length} file lạ sẽ bị stage · ${held.unknown.length} chưa rõ · session này sửa ${held.mine.length}`
105  }
106  const total = others + held.mine.length
107  const verb = held.kind === 'stash' ? 'sẽ bị cất vào stash' : 'sẽ mất thay đổi'
108  return `⚠ ${total} file ${verb} · ${others} không do session này sửa`
109}
110
111function describeGroups(paths: readonly string[], count = 3): string {
112  const groups = groupByDir(paths)
113  const shown = groups.slice(0, count).map(g => `${g.dir} ${g.count}`).join(', ')
114  return groups.length > count ? `${shown}, … ${groups.length - count} thư mục khác` : shown
115}
116
117/** First line of a command, cut to `max` characters, for one-line displays and deny texts. */
118function shortCommand(command: string, max = 120): string {
119  const first = command.split('\n')[0] ?? ''
120  const cut = first.length > max ? `${first.slice(0, max - 1)}…` : first
121  return command.includes('\n') ? `${cut} …` : cut
122}
123
124function fullList(held: Review): string {
125  const block = (title: string, paths: readonly string[]) =>
126    paths.length ? [`${title} (${paths.length}):`, ...paths.map(p => `  ${p}`)] : []
127  return [
128    `worktree-guard · ${shortCommand(held.command)}${held.isPending ? '' : ' (đã quyết định)'}`,
129    ...block('Lạ — dirty từ trước session, session này không sửa', held.foreign),
130    ...block('Chưa rõ — dirty trong session, không qua Edit/Write', held.unknown),
131    ...block('Của session này', held.mine),
132  ].join('\n')
133}
134
135/** Why the guard refused, worded for the model, which reads it as the tool's error. */
136function denial(decision: Decision, what: string): string {
137  const who = decision === 'none' ? 'no one answered the guard (dismissed, or a headless run with nobody to ask)' : 'the person chose to block it'
138  return `${PLUGIN}: ${what}; ${who}.`
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({ name: 'guard-files', description: 'List every file the last guarded git command touched' }).catch(() => undefined)
144    const id = await $.session.id()
145    const stored = await read($, sessionId)
146    await update($, review, () => null)
147    if (stored === KEEP) {
148      await update($, sessionId, () => id)
149    } else if (stored !== id) {
150      // A hot reload fires session.start again with the same id; only a new session takes a new baseline.
151      const where = await locate($, e.cwd)
152      const entries = where ? await statusOf($, where.top).catch(() => null) : null
153      await update($, root, () => (where && entries ? where.top : null))
154      await update($, baseline, () => (entries ?? []).map(x => x.path))
155      await update($, own, () => [])
156      await update($, sessionId, () => id)
157      if (where && !entries) $.ui.toast('worktree-guard: không đọc được git status lúc mở session, guard tắt cho session này')
158    }
159    return next(e)
160  })
161
162  // Records the files this session writes, so a later `git add -A` can tell them from other sessions' work.
163  on('tool.call', async ($, e, next) => {
164    const ran = await next(e)
165    if (!EDIT_TOOLS.includes(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
166    const top = await read($, root)
167    const path = 'file_path' in e ? e.file_path : 'notebook_path' in e ? e.notebook_path : undefined
168    const rel = top && typeof path === 'string' ? toRepoPath(top, path) : null
169    if (rel) await update($, own, list => (list.includes(rel) ? list : [...list, rel]))
170    return ran
171  })
172
173  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
174    const top = await read($, root)
175    const guarded = top ? findGuarded(e.command) : []
176    const decided = new Set<string>()
177    if (top && guarded.length) {
178      const cwd = await $.session.cwd()
179      for (const cmd of guarded) {
180        const dir = cmd.dir.startsWith('/') ? cmd.dir : cmd.dir ? `${cwd}/${cmd.dir}` : cwd
181        const where = await locate($, dir)
182        // Another repository (a `cd ../other`, a worktree of its own) is not this baseline's business.
183        if (!where || where.top !== top) continue
184        const touched = await touchedBy($, top, where.prefix, dir, cmd)
185        const key = [cmd.kind === 'stage' ? 'stage' : 'drop', ...[...touched].sort()].join('\0')
186        if (touched.length === 0 || decided.has(key)) continue
187        decided.add(key)
188        const split = classify(touched, await read($, baseline), await read($, own))
189        const kind = cmd.kind === 'stage' ? 'stage' : cmd.kind === 'stash' ? 'stash' : 'destructive'
190        const held: Review = { kind, command: e.command, ...split, isPending: true }
191
192        if (kind === 'stage') {
193          if (split.foreign.length === 0) {
194            if (split.unknown.length) {
195              $.ui.toast(`worktree-guard: ${split.unknown.length} file chưa rõ nguồn sẽ được stage: ${describeGroups(split.unknown)}`)
196            }
197            continue
198          }
199          const decision = await decide($, held, `Lệnh này stage ${split.foreign.length} file session này không sửa (${describeGroups(split.foreign)}). Vẫn chạy?`)
200          if (decision !== 'allow') {
201            const suggestion = split.mine.length ? ` Stage only this session's files: git add ${split.mine.map(shellQuote).join(' ')}` : ''
202            return { deny: denial(decision, `\`${shortCommand(e.command)}\` would stage ${split.foreign.length} file(s) this session did not edit (${describeGroups(split.foreign)}).${suggestion}`) }
203          }
204          continue
205        }
206
207        const others = split.foreign.length + split.unknown.length
208        const action = kind === 'stash' ? 'cất vào stash' : 'bỏ thay đổi của'
209        const decision = await decide($, held, `Lệnh này ${action} ${touched.length} file (${others} không do session này sửa: ${describeGroups([...split.foreign, ...split.unknown]) || 'không có'}). Vẫn chạy?`)
210        if (decision !== 'allow') {
211          return { deny: denial(decision, `\`${shortCommand(e.command)}\` would ${kind === 'stash' ? 'stash' : 'discard'} changes in ${touched.length} file(s) (${describeGroups(touched)})`) }
212        }
213      }
214    }
215
216    const ran = await next(e)
217    try {
218      const port = busyPort(ran.text ?? '')
219      if (port !== null) {
220        const owner = await portOwner($, port)
221        $.ui.toast(owner ? `worktree-guard: port ${port} đang bị PID ${owner.pid} (${owner.name}) giữ, cwd ${owner.cwd}` : `worktree-guard: port ${port} đang bận`, { timeoutMs: 10000 })
222      }
223    } catch {
224      // The command already ran; a failed port lookup must not fail the call.
225    }
226    return ran
227  }).catch(($, e, next) => {
228    // Fail closed: when the guard itself breaks before the command ran, a guarded command does not run.
229    if (next.called || findGuarded(e.command).length === 0) return next(e)
230    return { deny: `${PLUGIN}: could not check \`${shortCommand(e.command)}\` (${String(next.error).slice(0, 160)}), so it was blocked. Run it yourself if it is safe.` }
231  })
232
233  on('session.end', async ($, e, next) => {
234    const top = await read($, root)
235    if (e.reason === 'clear') await update($, sessionId, () => KEEP)
236    if (top) {
237      const listeners = await listenersIn($, top).catch(() => [])
238      const lines = listeners.map(l => `PID ${l.pid} ${l.name} :${l.port}`)
239      if (lines.length) $.ui.log(`worktree-guard: còn ${lines.length} process đang listen trong worktree: ${lines.join(', ')}`)
240    }
241    return next(e)
242  })
243
244  on('command.run', { command: 'guard-files' }, async $ => {
245    const held = await read($, review)
246    return { text: held ? fullList(held) : 'worktree-guard: chưa có lệnh git nào bị giữ trong session này.' }
247  })
248
249  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
250    const { Box, Text, Button } = $.ui.resolve(e)
251    const held = await read($, review)
252    if (!held) return <Text dimColor>Không có lệnh nào đang chờ.</Text>
253    const expanded = await read($, isExpanded)
254    const others = [...held.foreign, ...held.unknown]
255    const groups = groupByDir(held.kind === 'stage' ? held.foreign : others)
256    const hidden = groups.length - MAX_GROUPS
257    return (
258      <Box flexDirection="column" width={e.props.bodyColumns}>
259        <Text key="summary" bold color="warning" wrap="truncate-end">{summary(held)}</Text>
260        <Text key="command" dimColor wrap="truncate-middle">$ {shortCommand(held.command)}</Text>
261        <Box key="actions" gap={1}>
262          <Button key="expand" label={expanded ? 'Thu gọn' : 'Xem hết'} onPress={() => update($, isExpanded, v => !v)} />
263        </Box>
264        {expanded ? (
265          fullList(held).split('\n').slice(1).map((line, i) => <Text key={`line-${i}`} wrap="truncate-middle">{line}</Text>)
266        ) : (
267          <Box key="groups" flexDirection="column">
268            {groups.slice(0, MAX_GROUPS).map(g => (
269              <Text key={`group-${g.dir}`} wrap="truncate-middle">  {g.dir} ({g.count})</Text>
270            ))}
271            {hidden > 0 && <Text key="more" dimColor>  … và {hidden} thư mục khác</Text>}
272            {held.kind === 'stage' && held.unknown.length > 0 && <Text key="unknown" dimColor>  + {held.unknown.length} file chưa rõ nguồn</Text>}
273          </Box>
274        )}
275        {held.kind === 'stage' && held.mine.length > 0 && (
276          <Text key="suggest" bold wrap="truncate-end">→ git add {held.mine.map(shellQuote).join(' ')}</Text>
277        )}
278      </Box>
279    )
280  })
281
282  // While the pane has no seat (a narrow terminal), the band above the prompt carries one line instead.
283  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284    const below = await next(e)
285    const held = await read($, review)
286    if (!held?.isPending || e.props.hasSurvey) return below
287    const isSeated = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
288    if (isSeated) return below
289    const { Box, Text } = $.ui.resolve(e)
290    return (
291      <Box flexDirection="column">
292        <Text key="guard" color="warning" wrap="truncate-end">{summary(held)} · /guard-files để xem hết</Text>
293        {below}
294      </Box>
295    )
296  })
297}
298
299async function lsof($: EngineInterface, args: string[]): Promise<string> {
300  const ran = await $.process.run(['lsof', '-nP', ...args], { timeoutMs: 5000 }).catch(() => null)
301  return ran?.stdout ?? ''
302}
303
304async function cwdOf($: EngineInterface, pids: number[]): Promise<Map<number, string>> {
305  const map = new Map<number, string>()
306  if (!pids.length) return map
307  for (const rec of lsofRecords(await lsof($, ['-a', '-d', 'cwd', '-p', pids.join(','), '-Fpn']))) {
308    if (rec.names[0]) map.set(rec.pid, rec.names[0])
309  }
310  return map
311}
312
313/** The process listening on `port`, with its working directory. */
314async function portOwner($: EngineInterface, port: number): Promise<Listener | null> {
315  const rec = lsofRecords(await lsof($, [`-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn']))[0]
316  if (!rec) return null
317  const cwd = (await cwdOf($, [rec.pid])).get(rec.pid) ?? '?'
318  return { pid: rec.pid, name: rec.name, port, cwd }
319}
320
321/** Every listening process whose working directory lies inside `top`, with each of its ports. */
322async function listenersIn($: EngineInterface, top: string): Promise<Listener[]> {
323  const records = lsofRecords(await lsof($, ['-iTCP', '-sTCP:LISTEN', '-Fpcn']))
324  const cwds = await cwdOf($, [...new Set(records.map(r => r.pid))])
325  const prefix = `${top.replace(/\/$/, '')}/`
326  return records.flatMap(rec => {
327    const cwd = cwds.get(rec.pid)
328    if (!cwd || (cwd !== top && !cwd.startsWith(prefix))) return []
329    const ports = [...new Set(rec.names.map(n => Number(n.match(/:(\d+)$/)?.[1] ?? 0)).filter(Boolean))]
330    return ports.map(port => ({ pid: rec.pid, name: rec.name, port, cwd }))
331  })
332}
333
hooks/lib/commands.ts 228 lines
1/**
2 * Recognizes the git commands the guard cares about inside a Bash command line.
3 * Best effort by design: aliases, scripts and eval slip through; the guard is a safety net.
4 */
5
6export type GuardedCommand = (
7  /** Sweeps files into the index; `paths` empty means the whole tree. */
8  | { kind: 'stage'; isTrackedOnly: boolean; paths: string[] }
9  | { kind: 'stash'; withUntracked: boolean }
10  /** Overwrites working-tree files from the index (`source` absent) or from a commit. */
11  | { kind: 'discard'; source?: string; paths: string[] }
12  /** Deletes untracked files; `args` is the dry-run argument list that mirrors the command. */
13  | { kind: 'clean'; args: string[] }
14) & {
15  /** Where the command runs, relative to the session's directory ('' = there), from `cd` and `git -C`. */
16  dir: string
17}
18
19/** Drops here-document bodies, so text inside a commit message is never read as a command. */
20export function stripHeredocs(command: string): string {
21  const kept: string[] = []
22  let terminator: string | null = null
23  let isTabStripped = false
24  for (const line of command.split('\n')) {
25    if (terminator !== null) {
26      if ((isTabStripped ? line.replace(/^\t+/, '') : line) === terminator) terminator = null
27      continue
28    }
29    kept.push(line)
30    const open = line.match(/<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2/)
31    if (open) {
32      terminator = open[3] ?? null
33      isTabStripped = open[1] === '-'
34    }
35  }
36  return kept.join('\n')
37}
38
39/**
40 * Splits a command line into simple commands on unquoted `&&`, `||`, `;`, `|`, `&`,
41 * newlines and parentheses, each as its unquoted words.
42 */
43export function simpleCommands(command: string): string[][] {
44  const commands: string[][] = []
45  let words: string[] = []
46  let word = ''
47  let hasWord = false
48  let quote: '"' | "'" | null = null
49  const endWord = () => {
50    if (hasWord) words.push(word)
51    word = ''
52    hasWord = false
53  }
54  const endCommand = () => {
55    endWord()
56    if (words.length) commands.push(words)
57    words = []
58  }
59  const text = stripHeredocs(command)
60  for (let i = 0; i < text.length; i++) {
61    const ch = text[i] ?? ''
62    if (quote) {
63      if (ch === quote) quote = null
64      else if (ch === '\\' && quote === '"' && i + 1 < text.length) word += text[++i]
65      else word += ch
66      continue
67    }
68    if (ch === '"' || ch === "'") {
69      quote = ch
70      hasWord = true
71    } else if (ch === '\\' && i + 1 < text.length) {
72      const next = text[++i] ?? ''
73      if (next !== '\n') {
74        word += next
75        hasWord = true
76      }
77    } else if (/[;&|\n()]/.test(ch)) {
78      endCommand()
79    } else if (/\s/.test(ch)) {
80      endWord()
81    } else {
82      word += ch
83      hasWord = true
84    }
85  }
86  endCommand()
87  return commands
88}
89
90/** Words that run the command after them unchanged. */
91const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'time', 'nohup', 'builtin', '{'])
92/** git's global options that take a value as the next word. */
93const GLOBAL_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
94const STASH_SUBCOMMANDS = new Set(['push', 'save', 'list', 'show', 'pop', 'apply', 'drop', 'clear', 'branch', 'create', 'store'])
95
96/** Joins `cd` and `-C` steps the way the shell and git would, staying relative when they are. */
97export function joinDir(base: string, step: string): string {
98  if (step.startsWith('/')) return step
99  if (step === '' || step === '.') return base
100  return base ? `${base}/${step}` : step
101}
102
103/** Returns every guarded git command in the line, in order, each with the directory it runs in. */
104export function findGuarded(command: string): GuardedCommand[] {
105  const found: GuardedCommand[] = []
106  let dir = ''
107  for (const words of simpleCommands(command)) {
108    let at = 0
109    while (at < words.length && (WRAPPERS.has(words[at] ?? '') || /^[A-Za-z_]\w*=/.test(words[at] ?? ''))) at++
110    const name = words[at] ?? ''
111    if (name === 'cd') {
112      dir = joinDir(dir, words[at + 1] ?? '')
113      continue
114    }
115    if (name !== 'git' && !name.endsWith('/git')) continue
116    let runIn = dir
117    let i = at + 1
118    while (i < words.length && (words[i] ?? '').startsWith('-')) {
119      const option = words[i] ?? ''
120      if (option === '-C') runIn = joinDir(runIn, words[i + 1] ?? '')
121      i += GLOBAL_WITH_VALUE.has(option) ? 2 : 1
122    }
123    const one = match(words[i], words.slice(i + 1))
124    if (one) found.push({ ...one, dir: runIn })
125  }
126  return found
127}
128
129type Parsed = GuardedCommand extends infer G ? (G extends GuardedCommand ? Omit<G, 'dir'> : never) : never
130
131function match(verb: string | undefined, args: string[]): Parsed | null {
132  const dash = args.indexOf('--')
133  const before = dash >= 0 ? args.slice(0, dash) : args
134  const afterDash = dash >= 0 ? args.slice(dash + 1) : []
135  const flags = before.filter(arg => arg.startsWith('-'))
136  const operands = before.filter(arg => !arg.startsWith('-'))
137  const hasShort = (letter: string) => flags.some(flag => /^-[a-zA-Z]+$/.test(flag) && flag.includes(letter))
138  const has = (...names: string[]) => flags.some(flag => names.includes(flag))
139
140  switch (verb) {
141    case 'add': {
142      const paths = [...operands, ...afterDash]
143      const isTrackedOnly = has('-u', '--update')
144      if (paths.includes(':/')) return { kind: 'stage', isTrackedOnly, paths: [] }
145      if (isTrackedOnly || has('-A', '--all')) return { kind: 'stage', isTrackedOnly, paths }
146      return paths.includes('.') ? { kind: 'stage', isTrackedOnly: false, paths } : null
147    }
148    case 'commit':
149      return has('--all') || hasShort('a') ? { kind: 'stage', isTrackedOnly: true, paths: [] } : null
150    case 'stash': {
151      const sub = args[0] && !args[0].startsWith('-') ? args[0] : 'push'
152      if (!STASH_SUBCOMMANDS.has(sub) || (sub !== 'push' && sub !== 'save')) return null
153      return { kind: 'stash', withUntracked: has('-u', '--include-untracked', '-a', '--all') }
154    }
155    case 'checkout': {
156      if (dash >= 0) return { kind: 'discard', source: operands[0], paths: afterDash }
157      if (operands.includes('.')) return { kind: 'discard', source: operands.find(o => o !== '.'), paths: ['.'] }
158      return has('-f', '--force') ? { kind: 'discard', source: 'HEAD', paths: [] } : null
159    }
160    case 'switch':
161      return has('-f', '--force', '--discard-changes') ? { kind: 'discard', source: 'HEAD', paths: [] } : null
162    case 'restore': {
163      const isStaged = has('--staged') || hasShort('S')
164      const isWorktree = has('--worktree') || hasShort('W')
165      if (isStaged && !isWorktree) return null
166      let source: string | undefined
167      const rest: string[] = []
168      for (let i = 0; i < before.length; i++) {
169        const arg = before[i] ?? ''
170        if (arg === '-s' || arg === '--source') source = before[++i]
171        else if (arg.startsWith('--source=')) source = arg.slice('--source='.length)
172        else if (!arg.startsWith('-')) rest.push(arg)
173      }
174      const paths = [...rest, ...afterDash]
175      return { kind: 'discard', source: source ?? (isStaged ? 'HEAD' : undefined), paths: paths.length ? paths : ['.'] }
176    }
177    case 'reset':
178      return has('--hard') ? { kind: 'discard', source: operands[0] ?? 'HEAD', paths: [] } : null
179    case 'clean':
180      return cleanPreview(args)
181    default:
182      return null
183  }
184}
185
186/** Mirrors a `git clean` as `git clean -n` with the same scope; null when it is a dry run already. */
187function cleanPreview(args: string[]): Parsed | null {
188  const preview: string[] = []
189  for (let i = 0; i < args.length; i++) {
190    const arg = args[i] ?? ''
191    if (arg === '-n' || arg === '--dry-run') return null
192    if (arg === '--') {
193      preview.push(...args.slice(i))
194      break
195    }
196    if (arg === '-e' || arg === '--exclude') {
197      preview.push(arg, args[++i] ?? '')
198    } else if (arg.startsWith('--exclude=')) {
199      preview.push(arg)
200    } else if (['--force', '--interactive', '--quiet'].includes(arg)) {
201      continue
202    } else if (/^-[a-zA-Z]/.test(arg) && (/^-[a-zA-Z]+$/.test(arg) || arg.includes('e'))) {
203      // -e takes its value glued (`-epat`) or as the next word; letters before it are plain switches.
204      const at = arg.indexOf('e')
205      const switches = at >= 0 ? arg.slice(1, at) : arg.slice(1)
206      if (switches.includes('n')) return null
207      const letters = switches.replace(/[fiq]/g, '')
208      if (letters) preview.push(`-${letters}`)
209      if (at >= 0) preview.push('-e', arg.slice(at + 1) || (args[++i] ?? ''))
210    } else {
211      preview.push(arg)
212    }
213  }
214  return { kind: 'clean', args: preview }
215}
216
217/** Pulls the port out of a server's "address in use" error, if the text has one. */
218export function busyPort(text: string): number | null {
219  for (const line of text.split('\n')) {
220    if (!/listen EADDRINUSE|EADDRINUSE:|address already in use/i.test(line)) continue
221    const named = line.match(/port\D{0,3}(\d{2,5})\b/i)
222    const colons = [...line.matchAll(/:(\d{2,5})\b/g)]
223    const port = Number(named?.[1] ?? colons[colons.length - 1]?.[1])
224    if (Number.isInteger(port) && port > 0 && port < 65536) return port
225  }
226  return null
227}
228
hooks/lib/git-status.ts 101 lines
1/** One entry of `git status --porcelain=v1 -z`. */
2export type StatusEntry = { path: string; isUntracked: boolean }
3
4/**
5 * Parses `git status --porcelain=v1 -z --untracked-files=all` output.
6 * A rename or copy carries its source as a second NUL-separated field, which is skipped.
7 */
8export function parseStatus(out: string): StatusEntry[] {
9  const fields = out.split('\0')
10  const entries: StatusEntry[] = []
11  for (let i = 0; i < fields.length; i++) {
12    const field = fields[i] ?? ''
13    if (field.length < 4) continue
14    const code = field.slice(0, 2)
15    entries.push({ path: field.slice(3), isUntracked: code === '??' })
16    if (code[0] === 'R' || code[0] === 'C') i++
17  }
18  return entries
19}
20
21/** Turns an absolute path inside `root` into a repo-relative one; null when it lies outside. */
22export function toRepoPath(root: string, path: string): string | null {
23  const prefix = root.endsWith('/') ? root : `${root}/`
24  if (path.startsWith(prefix)) return path.slice(prefix.length)
25  // macOS spells temp folders both ways; the repo root comes back resolved from git.
26  if (path.startsWith('/tmp/') && prefix.startsWith('/private/tmp/')) return toRepoPath(root, `/private${path}`)
27  return null
28}
29
30/** Splits touched files by who made them dirty. */
31export function classify(touched: readonly string[], baseline: readonly string[], own: readonly string[]) {
32  const before = new Set(baseline)
33  const mine = new Set(own)
34  const result = { foreign: [] as string[], unknown: [] as string[], mine: [] as string[] }
35  for (const path of touched) {
36    if (mine.has(path)) result.mine.push(path)
37    else if (before.has(path)) result.foreign.push(path)
38    else result.unknown.push(path)
39  }
40  return result
41}
42
43export type Group = { dir: string; count: number }
44
45/** Groups paths by their first two directory levels, largest group first. */
46export function groupByDir(paths: readonly string[]): Group[] {
47  const counts = new Map<string, number>()
48  for (const path of paths) {
49    const parts = path.split('/').filter(Boolean)
50    const dir = parts.length <= 1 ? './' : `${parts.slice(0, Math.min(2, parts.length - 1)).join('/')}/`
51    counts.set(dir, (counts.get(dir) ?? 0) + 1)
52  }
53  return [...counts].map(([dir, count]) => ({ dir, count })).sort((a, b) => b.count - a.count || a.dir.localeCompare(b.dir))
54}
55
56/** Quotes a path for a shell suggestion only when it needs it. */
57export function shellQuote(path: string): string {
58  return /^[\w./@+-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`
59}
60
61/** Resolves `.` and `..` in a repo-relative path; null when it climbs out of the repository. */
62export function normalize(path: string): string | null {
63  const out: string[] = []
64  for (const part of path.split('/')) {
65    if (part === '' || part === '.') continue
66    if (part === '..') {
67      if (!out.length) return null
68      out.pop()
69    } else {
70      out.push(part)
71    }
72  }
73  return out.join('/')
74}
75
76/**
77 * Keeps the paths a pathspec list covers, each spec read relative to `prefix`
78 * (the command directory's place in the repo, `git rev-parse --show-prefix`).
79 * An empty list covers the whole tree; magic and glob specs fall back to covering it too.
80 */
81export function underPathspecs(paths: readonly string[], prefix: string, specs: readonly string[]): string[] {
82  if (!specs.length || specs.some(spec => spec.startsWith(':') || /[*?[]/.test(spec))) return [...paths]
83  const roots = specs.map(spec => normalize(`${prefix}${spec}`)).filter((root): root is string => root !== null)
84  return paths.filter(path => roots.some(root => root === '' || path === root || path.startsWith(`${root}/`)))
85}
86
87/** Expands `git clean -n` lines (repo-relative, directories ending in `/`) to the files status lists under them. */
88export function expandDirs(removed: readonly string[], untracked: readonly string[]): string[] {
89  const files = new Set<string>()
90  for (const entry of removed) {
91    if (!entry.endsWith('/')) {
92      files.add(entry)
93      continue
94    }
95    const inside = untracked.filter(path => path.startsWith(entry))
96    if (inside.length) inside.forEach(path => files.add(path))
97    else files.add(entry)
98  }
99  return [...files]
100}
101
hooks/lib/ports.ts 17 lines
1export type Listener = { pid: number; name: string; port: number; cwd: string }
2
3/** Parses `lsof -F` field output into one record per process. */
4export function lsofRecords(out: string): { pid: number; name: string; names: string[] }[] {
5  const records: { pid: number; name: string; names: string[] }[] = []
6  for (const line of out.split('\n')) {
7    const tag = line[0]
8    const value = line.slice(1)
9    if (tag === 'p') records.push({ pid: Number(value), name: '', names: [] })
10    const last = records[records.length - 1]
11    if (!last) continue
12    if (tag === 'c') last.name = value
13    if (tag === 'n') last.names.push(value)
14  }
15  return records
16}
17
types/index.d.ts 35 lines
1/** A git command the guard holds for the person's decision. */
2export type Review = {
3  /** `stage`: an add/commit that sweeps files in; `stash`: one that shelves changes; `destructive`: one that discards them. */
4  kind: 'stage' | 'stash' | 'destructive'
5  /** The command as the model wrote it. */
6  command: string
7  /** Files the command touches that were dirty before this session began and that it never edited. */
8  foreign: string[]
9  /** Files the command touches that became dirty during the session without an Edit or Write from it. */
10  unknown: string[]
11  /** Files the command touches that this session edited. */
12  mine: string[]
13  /** True while the person is being asked; the last review stays readable by /guard-files after. */
14  isPending: boolean
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'worktree-guard': {
20      /** The session the baseline belongs to, so a hot reload keeps it; `KEEP` after a /clear. */
21      sessionId: string | null
22      /** The repository root, or null outside a git repository. */
23      root: string | null
24      /** Repo-relative paths that were dirty when the session started. */
25      baseline: string[]
26      /** Repo-relative paths this session wrote through Edit, Write or NotebookEdit. */
27      own: string[]
28      /** The command waiting on the person, or the last one decided. */
29      review: Review | null
30      /** Whether the pane lists every file instead of the grouped summary. */
31      isExpanded: boolean
32    }
33  }
34}
35