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…

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.
git add -A, git add ., git add -u or git commit -a, the guard sorts the files that command would stage:git add <this session's files>.sed). These only raise a toast.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./guard-files prints the full list of the last guarded command.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.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:
git commit without -a, which commits whatever is already staged, possibly by another session;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.
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.
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.EADDRINUSE. It also reads the file path of each Edit, Write and NotebookEdit call, to know which files this session wrote.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.claude plugin marketplace add thieung/claude-mods
claude plugin install worktree-guard@thieung-modshooks/register.tsx 333 lines1import { 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}
333hooks/lib/commands.ts 228 lines1/**
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}
228hooks/lib/git-status.ts 101 lines1/** 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}
101hooks/lib/ports.ts 17 lines1export 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}
17types/index.d.ts 35 lines1/** 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