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.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- 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 }
|