claude-bulkhead 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. package/src/worktree-copy.js +82 -0
@@ -0,0 +1,454 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { spawnSync } = require('child_process')
5
+ const { option, rejectArgs, table, shortPath } = require('./cli-util')
6
+ const { selectFiles, copyFiles, isDefaultName, REBUILDABLE, git } = require('./worktree-copy')
7
+ const { stablePort } = require('./stable-port')
8
+
9
+ const USAGE = `usage:
10
+ bulkhead worktree add <name> [--base REF] [--path DIR] [--install|--no-install] [--json]
11
+ a new worktree ../<repo>-<name> on a new branch <name>, with your .env files copied
12
+ (.worktreeinclude, or a safe default set), dependencies installed and a stable PORT set
13
+ bulkhead worktree list [--json]
14
+ worktrees of this repo: branch, age, dirty, ahead/behind, live session, port
15
+ bulkhead worktree prune [--merged-into REF] [--older-than 7d] [--yes] [--json]
16
+ worktrees that are safe to remove (merged, clean, no live session); a dry run without --yes (or with --dry-run)`
17
+
18
+ // ---- small helpers -------------------------------------------------------------------------
19
+
20
+ function norm(p, platform = process.platform) {
21
+ let s = String(p).replace(/\\/g, '/').replace(/\/+$/, '')
22
+ if (platform === 'win32') s = s.toLowerCase()
23
+ return s
24
+ }
25
+
26
+ function inside(dir, root, platform) {
27
+ const d = norm(dir, platform)
28
+ const r = norm(root, platform)
29
+ return d === r || d.startsWith(r + '/')
30
+ }
31
+
32
+ function real(p) {
33
+ try { return fs.realpathSync(p) } catch { return p }
34
+ }
35
+
36
+ function age(elapsed) {
37
+ const s = Math.max(0, Math.round(elapsed))
38
+ if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m`
39
+ if (s < 86400) return `${Math.round(s / 3600)}h`
40
+ return `${Math.round(s / 86400)}d`
41
+ }
42
+
43
+ function parseDuration(text) {
44
+ const m = /^(\d+)([mhdw])$/.exec(String(text))
45
+ return m ? Number(m[1]) * { m: 60, h: 3600, d: 86400, w: 604800 }[m[2]] * 1000 : null
46
+ }
47
+
48
+ // First arguments that are not options; valued options swallow the word after them.
49
+ function positionals(args, valued) {
50
+ const out = []
51
+ for (let i = 0; i < args.length; i++) {
52
+ const a = args[i]
53
+ if (a.startsWith('--')) { if (valued.includes(a.slice(2)) && !a.includes('=')) i++; continue }
54
+ out.push(a)
55
+ }
56
+ return out
57
+ }
58
+
59
+ function parseWorktrees(text) {
60
+ return text.split(/\r?\n\r?\n/).filter(b => b.trim()).map(block => {
61
+ const w = { path: '', head: '', branch: null, detached: false, bare: false, locked: false, prunable: false }
62
+ for (const line of block.split(/\r?\n/)) {
63
+ const [key, ...rest] = line.split(' ')
64
+ const val = rest.join(' ')
65
+ if (key === 'worktree') w.path = val
66
+ else if (key === 'HEAD') w.head = val
67
+ else if (key === 'branch') w.branch = val.replace(/^refs\/heads\//, '')
68
+ else if (key === 'detached') w.detached = true
69
+ else if (key === 'bare') w.bare = true
70
+ else if (key === 'locked') w.locked = true
71
+ else if (key === 'prunable') w.prunable = true
72
+ }
73
+ return w
74
+ })
75
+ }
76
+
77
+ function defaultRun(cmd, args, { cwd, platform = process.platform }) {
78
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: platform === 'win32', maxBuffer: 64 * 1024 * 1024 })
79
+ return { status: r.status === null ? 1 : r.status, output: `${r.stdout || ''}${r.stderr || ''}` }
80
+ }
81
+
82
+ function defaultWhich(cmd, platform = process.platform, env = process.env) {
83
+ const exts = platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']
84
+ const dirs = (env.PATH || env.Path || '').split(platform === 'win32' ? ';' : ':').filter(Boolean)
85
+ for (const d of dirs) for (const e of exts) if (fs.existsSync(path.join(d, cmd + e.toLowerCase())) || fs.existsSync(path.join(d, cmd + e))) return true
86
+ return false
87
+ }
88
+
89
+ // null when sessions cannot be read at all, so callers that delete things can refuse.
90
+ function defaultSessions() {
91
+ try {
92
+ const { readProcesses } = require('./processes')
93
+ const { readSessionFiles, findSessions } = require('./sessions')
94
+ return findSessions(readProcesses(), readSessionFiles()).map(s => ({ pid: s.pid, cwd: s.cwd }))
95
+ } catch { return null }
96
+ }
97
+
98
+ function context(deps = {}) {
99
+ const platform = deps.platform || process.platform
100
+ return {
101
+ cwd: deps.cwd || process.cwd(),
102
+ stdout: deps.stdout || process.stdout,
103
+ stderr: deps.stderr || process.stderr,
104
+ platform,
105
+ now: deps.now || Date.now(),
106
+ git: deps.git || git,
107
+ run: deps.run || ((cmd, args, o) => defaultRun(cmd, args, { ...o, platform })),
108
+ which: deps.which || (cmd => defaultWhich(cmd, platform)),
109
+ getSessions: deps.getSessions || defaultSessions,
110
+ bind: deps.bind,
111
+ }
112
+ }
113
+
114
+ function fail(c, msg) {
115
+ c.stderr.write(`bulkhead worktree: ${msg}\n`)
116
+ process.exitCode = 1
117
+ }
118
+
119
+ function repo(c) {
120
+ const top = c.git(['rev-parse', '--show-toplevel'], c.cwd)
121
+ if (top.status !== 0) return null
122
+ const trees = parseWorktrees(c.git(['worktree', 'list', '--porcelain'], c.cwd).stdout)
123
+ return { top: top.stdout.trim(), trees, main: trees[0] ? trees[0].path : top.stdout.trim() }
124
+ }
125
+
126
+ function refExists(c, cwd, ref) {
127
+ return c.git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd).status === 0
128
+ }
129
+
130
+ function defaultBranch(c, mainDir) {
131
+ const sym = c.git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], mainDir)
132
+ if (sym.status === 0 && refExists(c, mainDir, sym.stdout.trim())) return sym.stdout.trim()
133
+ for (const b of ['main', 'master']) if (refExists(c, mainDir, b)) return b
134
+ const cur = c.git(['symbolic-ref', '--short', 'HEAD'], mainDir)
135
+ return cur.status === 0 ? cur.stdout.trim() : 'HEAD'
136
+ }
137
+
138
+ const PORT_LINE = /^\s*(?:export\s+)?PORT\s*=\s*["']?(\d+)/m
139
+
140
+ function readPort(dir) {
141
+ try {
142
+ const m = PORT_LINE.exec(fs.readFileSync(path.join(dir, '.env.local'), 'utf8'))
143
+ return m ? Number(m[1]) : null
144
+ } catch { return null }
145
+ }
146
+
147
+ // ---- install -------------------------------------------------------------------------------
148
+
149
+ // The installer for a folder: the packageManager field wins when its lockfile is there, then the
150
+ // lockfile that exists. null without a lockfile.
151
+ function detectInstaller(dir) {
152
+ const has = f => fs.existsSync(path.join(dir, f))
153
+ const berry = has('.yarnrc.yml')
154
+ const table_ = {
155
+ bun: { lock: ['bun.lock', 'bun.lockb'], args: ['install', '--frozen-lockfile'] },
156
+ pnpm: { lock: ['pnpm-lock.yaml'], args: ['install', '--frozen-lockfile'] },
157
+ yarn: { lock: ['yarn.lock'], args: berry ? ['install', '--immutable'] : ['install', '--frozen-lockfile'] },
158
+ npm: { lock: ['package-lock.json', 'npm-shrinkwrap.json'], args: ['ci'] },
159
+ }
160
+ let declared = null
161
+ try { declared = /^([a-z]+)@/.exec(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).packageManager || '') } catch {}
162
+ const order = declared && table_[declared[1]] ? [declared[1], ...Object.keys(table_)] : Object.keys(table_)
163
+ for (const pm of order) {
164
+ const lock = table_[pm].lock.find(has)
165
+ if (lock) return { pm, lock, args: table_[pm].args }
166
+ }
167
+ return null
168
+ }
169
+
170
+ function install(c, dir, want) {
171
+ const found = detectInstaller(dir)
172
+ if (want === false) return { ran: false, reason: 'skipped (--no-install)' }
173
+ if (!found) return { ran: false, reason: 'no lockfile', failed: want === true }
174
+ if (!c.which(found.pm)) return { ran: false, reason: `${found.pm} is not on PATH`, command: `${found.pm} ${found.args.join(' ')}`, failed: want === true }
175
+ const command = `${found.pm} ${found.args.join(' ')}`
176
+ const t = Date.now()
177
+ const r = c.run(found.pm, found.args, { cwd: dir })
178
+ const seconds = Math.round((Date.now() - t) / 100) / 10
179
+ const tail = String(r.output || '').trim().split('\n').slice(-12).join('\n')
180
+ if (r.status !== 0) return { ran: true, command, ok: false, seconds, reason: `exit ${r.status}`, tail, failed: true }
181
+ return { ran: true, command, ok: true, seconds }
182
+ }
183
+
184
+ // ---- add -----------------------------------------------------------------------------------
185
+
186
+ async function add(args, c) {
187
+ const spec = { flags: ['json', 'install', 'no-install'], valued: ['base', 'path'] }
188
+ const [name] = positionals(args, spec.valued)
189
+ if (!name) return fail(c, 'add needs a name, like: bulkhead worktree add fix-login')
190
+ if (rejectArgs('worktree add', args.filter((a, i) => i !== args.indexOf(name)), spec, c.stderr, USAGE)) return
191
+ if (args.includes('--install') && args.includes('--no-install')) return fail(c, '--install and --no-install cannot be used together')
192
+ if (name.startsWith('-') || c.git(['check-ref-format', '--branch', name], c.cwd).status !== 0) return fail(c, `${name} is not a usable branch name`)
193
+ const info = repo(c)
194
+ if (!info) return fail(c, 'not inside a git repository')
195
+ const base = option(args, 'base')
196
+ const explicit = option(args, 'path')
197
+ if (base === true || explicit === true) return fail(c, `--${base === true ? 'base' : 'path'} needs a value`)
198
+ const dest = explicit ? path.resolve(c.cwd, explicit) : path.join(path.dirname(info.main), `${path.basename(info.main)}-${name.replace(/[\\/]/g, '-')}`)
199
+ if (fs.existsSync(dest)) return fail(c, `${dest} already exists; pick another name or --path (nothing was changed)`)
200
+ if (refExists(c, c.cwd, `refs/heads/${name}`)) return fail(c, `branch ${name} already exists; pick another name (nothing was changed)`)
201
+ const created = c.git(['worktree', 'add', '-b', name, dest, base || 'HEAD'], c.cwd)
202
+ if (created.status !== 0) return fail(c, `git worktree add failed: ${(created.stderr || created.stdout).trim()}`)
203
+
204
+ const sel = selectFiles(info.top, c.git)
205
+ const copied = copyFiles(info.top, dest, sel.files)
206
+
207
+ const wantInstall = args.includes('--install') ? true : args.includes('--no-install') ? false : undefined
208
+ const inst = install(c, dest, wantInstall)
209
+
210
+ let port = readPort(dest)
211
+ let wrote = false
212
+ if (port === null) {
213
+ port = await stablePort(dest, undefined, { bind: c.bind, platform: c.platform })
214
+ if (port !== null) {
215
+ const file = path.join(dest, '.env.local')
216
+ let body = ''
217
+ try { body = fs.readFileSync(file, 'utf8') } catch {}
218
+ fs.writeFileSync(file, `${body}${body && !body.endsWith('\n') ? '\n' : ''}PORT=${port}\n`)
219
+ wrote = true
220
+ }
221
+ }
222
+ const next = `cd ${dest.includes(' ') ? `"${dest}"` : dest} && claude`
223
+ const result = { path: dest, branch: name, base: base || 'HEAD', copied, skipped: sel.skipped, copySource: sel.source, install: inst, port, portWritten: wrote, next }
224
+ if (inst.failed) process.exitCode = 1
225
+ if (args.includes('--json')) return c.stdout.write(JSON.stringify(result) + '\n')
226
+
227
+ const out = [`created ${shortPath(dest)} on new branch ${name} from ${base || 'HEAD'}`]
228
+ out.push(copied.length ? `copied ${copied.join(', ')} (${sel.source === 'default' ? 'default set' : '.worktreeinclude'})` : `copied nothing (${sel.source === 'default' ? 'no .env files found' : 'no .worktreeinclude match'})`)
229
+ for (const s of sel.skipped) out.push(`left out ${s.path}: ${s.why}`)
230
+ if (inst.ran) out.push(inst.ok ? `${inst.command}: done in ${inst.seconds}s` : `${inst.command}: failed (${inst.reason})\n${inst.tail}`)
231
+ else out.push(`install: ${inst.reason}`)
232
+ if (port === null) out.push('port: none free in 3000-3999, PORT not set')
233
+ else out.push(wrote ? `wrote PORT=${port} to .env.local` : `kept PORT=${port} already in .env.local`)
234
+ out.push(`next: ${next}`)
235
+ c.stdout.write(out.join('\n') + '\n')
236
+ }
237
+
238
+ // ---- inspecting worktrees ------------------------------------------------------------------
239
+
240
+ // pids of live sessions per worktree path; a session belongs to the deepest worktree around its cwd.
241
+ function sessionMap(sessions, trees, platform) {
242
+ const map = new Map(trees.map(t => [t.path, []]))
243
+ for (const s of sessions || []) {
244
+ if (!s.cwd) continue
245
+ let best = null
246
+ for (const t of trees) {
247
+ if ((inside(s.cwd, t.path, platform) || inside(real(s.cwd), real(t.path), platform)) && (!best || norm(t.path, platform).length > norm(best.path, platform).length)) best = t
248
+ }
249
+ if (best) map.get(best.path).push(s.pid)
250
+ }
251
+ return map
252
+ }
253
+
254
+ function lastActivity(c, t) {
255
+ const r = c.git(['log', '-1', '--format=%ct', t.branch || t.head || 'HEAD'], t.path)
256
+ let sec = r.status === 0 ? Number(r.stdout.trim()) || 0 : 0
257
+ try {
258
+ const st = fs.statSync(t.path)
259
+ sec = Math.max(sec, st.mtimeMs / 1000, st.birthtimeMs / 1000)
260
+ } catch {}
261
+ return sec
262
+ }
263
+
264
+ function inspect(c, info, base, sessions) {
265
+ const map = sessionMap(sessions, info.trees, c.platform)
266
+ return info.trees.map((t, i) => {
267
+ const present = fs.existsSync(t.path)
268
+ const row = { path: t.path, branch: t.branch, main: i === 0, detached: t.detached, locked: t.locked, missing: t.prunable || !present, ahead: null, behind: null, dirty: null, ageSeconds: null, sessions: map.get(t.path), port: present ? readPort(t.path) : null }
269
+ if (!present) return row
270
+ // the .env copies and the PORT line `worktree add` wrote are not the user's changes
271
+ const st = c.git(['status', '--porcelain'], t.path)
272
+ row.dirty = st.status === 0 ? st.stdout.split('\n').filter(l => l && !(l.startsWith('?? ') && isDefaultName(l.slice(3)))).length > 0 : null
273
+ const lr = c.git(['rev-list', '--left-right', '--count', `${base}...HEAD`], t.path)
274
+ if (lr.status === 0) [row.behind, row.ahead] = lr.stdout.trim().split(/\s+/).map(Number)
275
+ const ct = c.git(['log', '-1', '--format=%ct'], t.path)
276
+ row.ageSeconds = ct.status === 0 ? Math.round(c.now / 1000 - Number(ct.stdout.trim())) : null
277
+ return row
278
+ })
279
+ }
280
+
281
+ function list(args, c) {
282
+ if (rejectArgs('worktree list', args, { flags: ['json'], valued: [] }, c.stderr, USAGE)) return
283
+ const info = repo(c)
284
+ if (!info) return fail(c, 'not inside a git repository')
285
+ const base = defaultBranch(c, info.main)
286
+ const sessions = c.getSessions()
287
+ const rows = inspect(c, info, base, sessions)
288
+ if (args.includes('--json')) return c.stdout.write(JSON.stringify({ base, sessionsKnown: sessions !== null, worktrees: rows }) + '\n')
289
+ const cells = [['PATH', 'BRANCH', 'AGE', 'STATE', `VS ${base}`, 'SESSION', 'PORT']]
290
+ for (const r of rows) {
291
+ cells.push([
292
+ shortPath(r.path),
293
+ (r.branch || (r.detached ? '(detached)' : '?')) + (r.main ? ' (main)' : ''),
294
+ r.ageSeconds === null ? '-' : age(r.ageSeconds),
295
+ r.missing ? 'missing' : r.dirty === null ? '?' : r.dirty ? 'dirty' : 'clean',
296
+ r.ahead === null ? '-' : `+${r.ahead}/-${r.behind}`,
297
+ sessions === null ? '?' : r.sessions.length ? r.sessions.map(p => `pid ${p}`).join(', ') : '-',
298
+ r.port === null ? '-' : String(r.port),
299
+ ])
300
+ }
301
+ c.stdout.write(table(cells).join('\n') + '\n')
302
+ }
303
+
304
+ // ---- prune ---------------------------------------------------------------------------------
305
+
306
+ // Entries of git status that would be lost when the worktree goes. Tracked changes, untracked
307
+ // files that are not env copies, and ignored files that are not rebuildable all count.
308
+ function lostWork(c, t, mainDir, includeSet) {
309
+ const st = c.git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching'], t.path)
310
+ if (st.status !== 0) return { why: 'git status failed', disposable: [] }
311
+ const parts = st.stdout.split('\0').filter(Boolean)
312
+ const disposable = []
313
+ for (let i = 0; i < parts.length; i++) {
314
+ const code = parts[i].slice(0, 2)
315
+ const rel = parts[i].slice(3)
316
+ if (code[0] === 'R' || code[0] === 'C') i++
317
+ if (code !== '??' && code !== '!!') return { why: 'uncommitted changes', disposable }
318
+ const segs = rel.replace(/\/$/, '').split('/')
319
+ const envCopy = !rel.endsWith('/') && ((segs.length === 1 && isDefaultName(segs[0])) || includeSet.has(rel))
320
+ if (code === '!!') {
321
+ if (envCopy || segs.some(s => REBUILDABLE.has(s)) || /\.log$/.test(rel)) continue
322
+ return { why: `ignored file ${rel} would be lost`, disposable }
323
+ }
324
+ if (!envCopy) return { why: `untracked ${rel}`, disposable }
325
+ disposable.push(rel)
326
+ }
327
+ // an untracked env copy may be deleted only when it is a plain copy of the main checkout's file
328
+ // or the PORT line this tool wrote; anything else is the user's own content
329
+ for (const rel of disposable) {
330
+ let mine
331
+ try { mine = fs.readFileSync(path.join(t.path, rel), 'utf8') } catch { continue }
332
+ let theirs = null
333
+ try { theirs = fs.readFileSync(path.join(mainDir, rel), 'utf8') } catch {}
334
+ const portOnly = rel === '.env.local' && /^\s*PORT=\d+\s*$/.test(mine)
335
+ if (mine !== theirs && !portOnly) return { why: `untracked ${rel} differs from the main checkout`, disposable }
336
+ }
337
+ return { why: null, disposable }
338
+ }
339
+
340
+ // A branch merged with --squash or rebased is not an ancestor of ref, but merging it into ref
341
+ // changes nothing: the merge result has ref's own tree. Needs git 2.38+ (merge-tree --write-tree);
342
+ // older git answers no and the branch is kept.
343
+ function squashMerged(c, dir, branch, ref) {
344
+ const m = c.git(['merge-tree', '--write-tree', ref, branch], dir)
345
+ if (m.status !== 0) return false
346
+ const t = c.git(['rev-parse', `${ref}^{tree}`], dir)
347
+ return t.status === 0 && m.stdout.trim().split('\n')[0] === t.stdout.trim()
348
+ }
349
+
350
+ function judge(c, t, ctx) {
351
+ const { info, ref, olderThan, sessions } = ctx
352
+ if (t.path === info.main) return 'main worktree'
353
+ if (t.bare) return 'bare'
354
+ if (t.locked) return 'locked'
355
+ if (t.prunable || !fs.existsSync(t.path)) return 'folder is missing (git worktree prune clears it)'
356
+ if (!t.branch) return 'detached HEAD'
357
+ if (sessions === null) return 'live sessions could not be read'
358
+ if (sessionMap(sessions, [t], c.platform).get(t.path).length) return 'a live session works in it'
359
+ if (inside(c.cwd, t.path, c.platform) || inside(real(c.cwd), real(t.path), c.platform)) return 'you are inside it'
360
+ const merged = c.git(['merge-base', '--is-ancestor', t.branch, ref], info.main).status === 0
361
+ if (!merged) {
362
+ if (!squashMerged(c, info.main, t.branch, ref)) return `not merged into ${ref}`
363
+ ctx.squashed.add(t.branch)
364
+ }
365
+ // a branch nobody has committed to is "merged" into anything ahead of it; its reflog has the creation entry only
366
+ const untouched = c.git(['reflog', 'show', '--format=%H', `refs/heads/${t.branch}`], info.main).stdout.split('\n').filter(Boolean).length <= 1
367
+ const idle = c.now / 1000 - lastActivity(c, t)
368
+ if (untouched && olderThan === null) return 'no commits of its own yet (new?); add --older-than to include it'
369
+ if (olderThan !== null && idle * 1000 < olderThan) return `touched ${age(idle)} ago`
370
+ const inc = fs.existsSync(path.join(info.main, '.worktreeinclude'))
371
+ ? c.git(['ls-files', '-z', '-o', '-i', `--exclude-from=${path.join(info.main, '.worktreeinclude')}`], t.path).stdout.split('\0').filter(Boolean)
372
+ : []
373
+ const lost = lostWork(c, t, info.main, new Set(inc))
374
+ ctx.disposable.set(t.path, lost.disposable)
375
+ return lost.why
376
+ }
377
+
378
+ function prune(args, c) {
379
+ // --dry-run is accepted because it is what prune does without --yes
380
+ const spec = { flags: ['yes', 'json', 'dry-run'], valued: ['merged-into', 'older-than'] }
381
+ if (rejectArgs('worktree prune', args, spec, c.stderr, USAGE)) return
382
+ const info = repo(c)
383
+ if (!info) return fail(c, 'not inside a git repository')
384
+ const refArg = option(args, 'merged-into')
385
+ const ref = refArg && refArg !== true ? refArg : defaultBranch(c, info.main)
386
+ if (!refExists(c, info.main, ref)) return fail(c, `${ref} is not a branch or commit here`)
387
+ const ageArg = option(args, 'older-than')
388
+ const olderThan = ageArg === undefined ? null : parseDuration(ageArg)
389
+ if (ageArg !== undefined && olderThan === null) return fail(c, `--older-than needs a number and m, h, d or w, like 7d (got ${ageArg})`)
390
+ const ctx = { info, ref, olderThan, sessions: c.getSessions(), disposable: new Map(), squashed: new Set() }
391
+ const keep = []
392
+ const safe = []
393
+ for (const t of info.trees) {
394
+ const why = judge(c, t, ctx)
395
+ if (why === 'main worktree') continue
396
+ ;(why ? keep : safe).push({ path: t.path, branch: t.branch, why, squashed: ctx.squashed.has(t.branch) })
397
+ }
398
+ const removed = []
399
+ const failed = []
400
+ if (args.includes('--yes')) {
401
+ for (const s of safe) {
402
+ const err = removeOne(c, info, ctx, s)
403
+ if (err) failed.push({ ...s, error: err })
404
+ else removed.push(s)
405
+ }
406
+ }
407
+ if (failed.length) process.exitCode = 1
408
+ if (args.includes('--json')) return c.stdout.write(JSON.stringify({ ref, dryRun: !args.includes('--yes'), safe: safe.map(s => ({ path: s.path, branch: s.branch, squashed: s.squashed })), kept: keep, removed: removed.map(s => ({ path: s.path, branch: s.branch })), failed }) + '\n')
409
+ const out = []
410
+ const verb = args.includes('--yes') ? 'removed' : 'would remove'
411
+ const done = args.includes('--yes') ? removed : safe
412
+ for (const s of done) out.push(`${verb} ${shortPath(s.path)} (${s.branch}${s.squashed ? ', squash-merged' : ''})`)
413
+ for (const f of failed) out.push(`could not remove ${shortPath(f.path)}: ${f.error}`)
414
+ for (const k of keep) out.push(`keep ${shortPath(k.path)} (${k.branch || 'detached'}): ${k.why}`)
415
+ if (!out.length) out.push('no other worktrees')
416
+ if (!args.includes('--yes') && safe.length) out.push(`dry run: run again with --yes to remove ${safe.length === 1 ? 'it' : 'them'} (merged branches are deleted with git branch -d, squash-merged ones with -D)`)
417
+ c.stdout.write(out.join('\n') + '\n')
418
+ }
419
+
420
+ // Re-reads the sessions right before deleting: one may have started in the folder since the plan
421
+ // was made. Never forces; git itself refuses a worktree with changes.
422
+ function removeOne(c, info, ctx, s) {
423
+ const sessions = c.getSessions()
424
+ if (sessions === null) return 'live sessions could not be read'
425
+ if (sessionMap(sessions, [{ path: s.path }], c.platform).get(s.path).length) return 'a live session started working in it'
426
+ for (const rel of ctx.disposable.get(s.path) || []) {
427
+ try { fs.rmSync(path.join(s.path, rel), { force: true }) } catch {}
428
+ }
429
+ const r = c.git(['worktree', 'remove', s.path], info.main)
430
+ if (r.status !== 0) return (r.stderr || r.stdout).trim().split('\n')[0]
431
+ // -d refuses a squash-merged branch; its changes are in ref already (squashMerged checked the tree)
432
+ const b = c.git(['branch', s.squashed ? '-D' : '-d', s.branch], info.main)
433
+ if (b.status !== 0) return `worktree removed, branch ${s.branch} kept: ${(b.stderr || b.stdout).trim().split('\n')[0]}`
434
+ return null
435
+ }
436
+
437
+ // ---- entry ---------------------------------------------------------------------------------
438
+
439
+ async function main(args, deps) {
440
+ const c = context(deps)
441
+ const [sub, ...rest] = args
442
+ if (!sub || sub === '--help' || sub === '-h' || rest.includes('--help') || rest.includes('-h')) {
443
+ c.stdout.write(USAGE + '\n')
444
+ if (sub && !['--help', '-h'].includes(sub) && !['add', 'list', 'prune'].includes(sub)) process.exitCode = 1
445
+ return
446
+ }
447
+ if (sub === 'add') return add(rest, c)
448
+ if (sub === 'list') return list(rest, c)
449
+ if (sub === 'prune') return prune(rest, c)
450
+ c.stderr.write(`bulkhead worktree: unknown command ${sub}\n${USAGE}\n`)
451
+ process.exitCode = 1
452
+ }
453
+
454
+ module.exports = { main, parseWorktrees, detectInstaller, norm, inside, parseDuration, sessionMap, USAGE }
@@ -0,0 +1,82 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { spawnSync } = require('child_process')
5
+
6
+ // Which untracked files a new worktree needs from the checkout it was made from.
7
+ // .worktreeinclude is the same file Claude Code reads (https://code.claude.com/docs/en/worktrees):
8
+ // gitignore syntax, and only files that are also gitignored are copied. git does the matching.
9
+
10
+ const MAX_BYTES = 5 * 1024 * 1024
11
+ const DEFAULT_NAME = /^(?:\.env|\.env\..+|\.envrc|\.tool-versions|\.nvmrc)$/
12
+ const TEMPLATE = /^\.env(?:\..+)?\.(?:example|sample|template|dist)$/
13
+ // never copied, whatever the include file says
14
+ const NEVER = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.cache', 'coverage', 'target', '.venv', 'venv', '__pycache__'])
15
+ // ignored folders and files a worktree can lose without losing work
16
+ const REBUILDABLE = new Set([...NEVER, '.parcel-cache', '.vite', '.svelte-kit', '.pytest_cache', '.DS_Store', '.eslintcache'])
17
+
18
+ function git(args, cwd, input) {
19
+ const r = spawnSync('git', args, { cwd, input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
20
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }
21
+ }
22
+
23
+ function lines(s) {
24
+ return s.split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean)
25
+ }
26
+
27
+ function isDefaultName(name) {
28
+ return DEFAULT_NAME.test(name) && !TEMPLATE.test(name)
29
+ }
30
+
31
+ function inNeverDir(rel) {
32
+ return rel.split('/').some(seg => NEVER.has(seg))
33
+ }
34
+
35
+ // Relative (forward slash) paths of the files to copy, plus what was left out and why.
36
+ function selectFiles(root, run = git) {
37
+ const include = path.join(root, '.worktreeinclude')
38
+ const skipped = []
39
+ let rels
40
+ let source
41
+ if (fs.existsSync(include)) {
42
+ source = '.worktreeinclude'
43
+ const r = run(['ls-files', '-z', '-o', '-i', '--exclude-from=.worktreeinclude', '--', '.', ':(exclude)node_modules', ':(exclude)**/node_modules'], root)
44
+ rels = r.status === 0 ? r.stdout.split('\0').filter(Boolean) : []
45
+ if (rels.length) {
46
+ const ign = run(['check-ignore', '-z', '--stdin'], root, rels.join('\0') + '\0')
47
+ const set = new Set(ign.stdout.split('\0').filter(Boolean))
48
+ for (const rel of rels) if (!set.has(rel)) skipped.push({ path: rel, why: 'not gitignored' })
49
+ rels = rels.filter(rel => set.has(rel))
50
+ }
51
+ } else {
52
+ source = 'default'
53
+ let names = []
54
+ try { names = fs.readdirSync(root).filter(isDefaultName) } catch {}
55
+ const tracked = new Set(lines(run(['ls-files', '--', ...names], root).stdout))
56
+ rels = names.filter(n => !tracked.has(n))
57
+ }
58
+ const files = []
59
+ for (const rel of rels) {
60
+ if (inNeverDir(rel)) { skipped.push({ path: rel, why: 'dependency or build folder' }); continue }
61
+ let st
62
+ try { st = fs.lstatSync(path.join(root, rel)) } catch { continue }
63
+ if (!st.isFile()) { skipped.push({ path: rel, why: 'not a regular file' }); continue }
64
+ if (st.size > MAX_BYTES) { skipped.push({ path: rel, why: 'over 5 MB' }); continue }
65
+ files.push(rel)
66
+ }
67
+ return { source, files, skipped }
68
+ }
69
+
70
+ function copyFiles(from, to, rels) {
71
+ const copied = []
72
+ for (const rel of rels) {
73
+ const dest = path.join(to, rel)
74
+ if (fs.existsSync(dest)) continue
75
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
76
+ fs.copyFileSync(path.join(from, rel), dest)
77
+ copied.push(rel)
78
+ }
79
+ return copied
80
+ }
81
+
82
+ module.exports = { selectFiles, copyFiles, isDefaultName, inNeverDir, REBUILDABLE, MAX_BYTES, git }