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
package/src/checkout-guard.js
CHANGED
|
@@ -4,7 +4,8 @@ const path = require('path')
|
|
|
4
4
|
const { execFileSync } = require('child_process')
|
|
5
5
|
const os = require('os')
|
|
6
6
|
const { runHook, blocked, oneLine, enabled, ASK_USER } = require('./hook')
|
|
7
|
-
const { readSessionFiles } = require('./sessions')
|
|
7
|
+
const { readSessionFiles, readLiveSessionFiles, PID_REUSE_SLACK_MS } = require('./sessions')
|
|
8
|
+
const { agentLabel } = require('./agents')
|
|
8
9
|
const shell = require('./shell')
|
|
9
10
|
const { rmTargets } = require('./basics')
|
|
10
11
|
|
|
@@ -13,50 +14,121 @@ function gitCommands(cmd, mode = 'sh') {
|
|
|
13
14
|
return shell.commands(shell.prepare(String(cmd || ''), mode), mode).filter(c => !c.remote && shell.bin(c.argv[0]) === 'git')
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const { gitCall: parseGitCall, gitEnv, treeOf, parseArgs } = require('./gitargs')
|
|
18
|
+
|
|
19
|
+
// git <sub> args, global options (-C, --git-dir, --work-tree, -c, --no-pager, ...) pulled out.
|
|
20
|
+
// Returns null for anything that is not git. Takes the words of one command, or a command line
|
|
21
|
+
// (read as its first command).
|
|
22
|
+
function gitCall(seg, cwd, env) {
|
|
19
23
|
const t = Array.isArray(seg) ? seg : ((shell.commands(seg)[0] || {}).argv || [])
|
|
20
|
-
|
|
21
|
-
let dir = cwd
|
|
22
|
-
const rest = []
|
|
23
|
-
for (let i = 1; i < t.length; i++) {
|
|
24
|
-
if (t[i] === '-C' && t[i + 1]) dir = path.resolve(dir, t[++i])
|
|
25
|
-
else if (t[i] === '-c') i++
|
|
26
|
-
else rest.push(t[i])
|
|
27
|
-
}
|
|
28
|
-
return { dir, sub: rest[0], args: rest.slice(1) }
|
|
24
|
+
return parseGitCall(t, cwd, env)
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
const DONE = /^--(?:abort|continue|skip|quit)$/
|
|
32
28
|
|
|
29
|
+
// A long option may be shortened as long as it stays unambiguous: --upd is --update.
|
|
30
|
+
const longIs = (o, full, min = 3) => [...o.long].some(n => n.length >= min && full.startsWith(n))
|
|
31
|
+
|
|
32
|
+
// A pathspec that excludes (:!x, :^x, :(exclude)x) narrows nothing down.
|
|
33
|
+
const excluding = p => /^:(?:[!^]|\([^)]*\bexclude\b[^)]*\))/.test(p)
|
|
34
|
+
|
|
35
|
+
// A list of changed files that git prints, as an argument: $(git ls-files -m), $(git diff --name-only).
|
|
36
|
+
// Naming a folder or file ($(git ls-files -m src)) limits the list to it.
|
|
37
|
+
function changedList(p) {
|
|
38
|
+
if (!/^(?:\$\(|`)/.test(p)) return false
|
|
39
|
+
const inner = shell.commands(p.replace(/^(?:\$\(|`)/, '').replace(/(?:\)|`)$/, ''))[0]
|
|
40
|
+
const call = inner && parseGitCall(inner.argv, '.')
|
|
41
|
+
const o = call && parseArgs(call.args)
|
|
42
|
+
if (!call || o.pos.length) return false
|
|
43
|
+
if (call.sub === 'diff') return o.long.has('name-only') || o.long.has('name-status')
|
|
44
|
+
if (call.sub === 'status') return o.long.has('porcelain') || o.long.has('short') || o.short.has('s')
|
|
45
|
+
return call.sub === 'ls-files'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A pathspec that stands for the whole tree: ., ./, *, ./*, .*, :/, :(top), :(glob)**, $PWD,
|
|
49
|
+
// $(pwd), $(git rev-parse --show-toplevel), and a list of every changed file.
|
|
50
|
+
function wholeTree(p) {
|
|
51
|
+
if (/^(?:\$\{?PWD\}?|\$\(\s*pwd\s*\)|`\s*pwd\s*`)\/?$/.test(p)) return true
|
|
52
|
+
if (/^(?:\$\(|`)\s*git\s+rev-parse\s+--show-toplevel\s*(?:\)|`)\/?$/.test(p)) return true
|
|
53
|
+
if (changedList(p)) return true
|
|
54
|
+
const rest = p.replace(/^:(?:\/|\((?:[a-z]+,?)+\))?/i, '').replace(/^(?:\.\/+)+/, '').replace(/\/+$/, '')
|
|
55
|
+
return /^(?:|\.|\*\*?|\.\*|\*\/\*\*)$/.test(rest)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A pathspec written to leave the folder git runs in: it can still name the whole tree.
|
|
59
|
+
const farPath = p => /^(?:[\\/]|~|\.\.|[A-Za-z]:)/.test(p)
|
|
60
|
+
|
|
61
|
+
// A pathspec that covers many files: a glob, a folder, a magic pathspec.
|
|
62
|
+
function broad(p, dir, isDir) {
|
|
63
|
+
if (/[*?[]|^:|[\\/]$/.test(p)) return true
|
|
64
|
+
return isDir(path.resolve(dir, p))
|
|
65
|
+
}
|
|
66
|
+
const dirOf = p => { try { return fs.statSync(p).isDirectory() } catch { return false } }
|
|
67
|
+
|
|
68
|
+
// The pathspecs a checkout or restore overwrites files from, or [] when it switches something.
|
|
69
|
+
function restoreSpecs({ sub, args, dir = '.' }, exists) {
|
|
70
|
+
if (sub === 'restore') {
|
|
71
|
+
const o = parseArgs(args, { valuedShort: 's', valuedLong: ['source', 'pathspec-from-file'] })
|
|
72
|
+
// --staged alone only unstages; the files on disk stay as they are
|
|
73
|
+
const worktree = o.short.has('W') || o.long.has('worktree')
|
|
74
|
+
if ((o.short.has('S') || o.long.has('staged')) && !worktree) return []
|
|
75
|
+
return o.pos
|
|
76
|
+
}
|
|
77
|
+
const o = parseArgs(args, { valuedShort: 'bBt', valuedLong: ['orphan', 'conflict', 'pathspec-from-file', 'track'] })
|
|
78
|
+
if (o.after.length) return o.after
|
|
79
|
+
if (o.short.has('b') || o.short.has('B') || o.long.has('orphan')) return []
|
|
80
|
+
// git checkout src/app.js restores a file; only a branch or commit moves the tree
|
|
81
|
+
return o.pos.length && o.pos.every(n => exists(path.resolve(dir, n))) ? o.pos : []
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
// What the command does to a shared working tree, or null when it leaves it alone.
|
|
34
|
-
// 'dirty-only' means it matters only when there are uncommitted changes.
|
|
35
|
-
|
|
36
|
-
|
|
85
|
+
// 'dirty-only' means it matters only when there are uncommitted changes. Three more answers need
|
|
86
|
+
// a look at the repository first and are settled by classify: 'add-paths' (does a far pathspec
|
|
87
|
+
// name the whole tree), 'restore-paths' (does a folder or glob cover uncommitted changes) and
|
|
88
|
+
// 'unstages' (is anything staged).
|
|
89
|
+
function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync, isDir = dirOf) {
|
|
90
|
+
// git <cmd> --help and -h only print usage
|
|
91
|
+
const before = args.indexOf('--') >= 0 ? args.slice(0, args.indexOf('--')) : args
|
|
92
|
+
if (before.includes('--help') || before.includes('-h')) return null
|
|
37
93
|
switch (sub) {
|
|
38
94
|
case 'checkout': {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
95
|
+
const o = parseArgs(args, { valuedShort: 'bBt', valuedLong: ['orphan', 'conflict', 'pathspec-from-file', 'track'] })
|
|
96
|
+
const specs = restoreSpecs({ sub, args, dir }, exists)
|
|
97
|
+
const restores = () => (specs.some(wholeTree) ? 'restores every file' : specs.some(p => broad(p, dir, isDir)) ? 'restore-paths' : null)
|
|
98
|
+
if (o.after.length) return restores()
|
|
99
|
+
if (args.includes('-') || o.short.has('b') || o.short.has('B') || o.long.has('orphan')) return 'switches the branch'
|
|
100
|
+
if (o.pos.some(wholeTree)) return 'restores every file'
|
|
101
|
+
if (specs.length) return restores()
|
|
102
|
+
if (o.pos.length) return 'switches the branch'
|
|
103
|
+
// git checkout -f with nothing to switch to throws the changes away
|
|
104
|
+
return o.short.has('f') || o.long.has('force') ? 'restores every file' : null
|
|
46
105
|
}
|
|
47
106
|
case 'switch':
|
|
48
107
|
return 'switches the branch'
|
|
49
108
|
case 'restore': {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
109
|
+
const specs = restoreSpecs({ sub, args, dir }, exists)
|
|
110
|
+
if (specs.some(wholeTree)) return 'restores every file'
|
|
111
|
+
return specs.some(p => broad(p, dir, isDir)) ? 'restore-paths' : null
|
|
112
|
+
}
|
|
113
|
+
case 'stash': {
|
|
114
|
+
const first = args[0]
|
|
115
|
+
if (/^(?:list|show|create|store)$/.test(first || '')) return null
|
|
116
|
+
const explicit = first === 'push'
|
|
117
|
+
if (first && !explicit && !first.startsWith('-')) return /^(?:save|pop|apply|drop|clear|branch)$/.test(first) ? 'stashes or unstashes changes' : null
|
|
118
|
+
// git stash push -- src/a.js moves only those files: allowed
|
|
119
|
+
const o = parseArgs(explicit ? args.slice(1) : args, { valuedShort: 'm', valuedLong: ['message', 'pathspec-from-file'] })
|
|
120
|
+
return o.pos.length || o.long.has('pathspec-from-file') ? null : 'stashes or unstashes changes'
|
|
121
|
+
}
|
|
122
|
+
case 'reset': {
|
|
123
|
+
const o = parseArgs(args)
|
|
124
|
+
if (o.long.has('hard') || o.long.has('merge') || o.long.has('keep')) return 'resets the files'
|
|
125
|
+
if (o.long.has('soft') || o.long.has('patch') || o.short.has('p')) return null
|
|
126
|
+
// a word with a variable may be a list of files; it is not guessed at
|
|
127
|
+
if (o.pos.some(p => p.includes('$'))) return null
|
|
128
|
+
const specs = o.after.length ? o.after : o.pos.length > 1 ? o.pos.slice(1) : o.pos.length === 1 && exists(path.resolve(dir, o.pos[0])) ? o.pos : []
|
|
129
|
+
if (specs.length && !specs.some(wholeTree)) return null
|
|
130
|
+
return 'unstages'
|
|
55
131
|
}
|
|
56
|
-
case 'stash':
|
|
57
|
-
return !args[0] || /^(?:push|save|pop|apply|-u|--include-untracked|-k|--keep-index|-m)$/.test(args[0]) ? 'stashes or unstashes changes' : null
|
|
58
|
-
case 'reset':
|
|
59
|
-
return args.includes('--hard') ? 'resets the files' : null
|
|
60
132
|
case 'clean':
|
|
61
133
|
if (args.some(a => /^-[a-z]*n/.test(a) || a === '--dry-run')) return null
|
|
62
134
|
return args.some(a => /^-[a-z]*f/.test(a) || a === '--force') ? 'deletes untracked files' : null
|
|
@@ -66,12 +138,56 @@ function changesTree({ sub, args, dir = '.' }, exists = fs.existsSync) {
|
|
|
66
138
|
return args.some(a => DONE.test(a)) ? null : 'merges into the branch'
|
|
67
139
|
case 'pull':
|
|
68
140
|
return 'dirty-only'
|
|
141
|
+
case 'cherry-pick':
|
|
142
|
+
return args.some(a => DONE.test(a)) ? null : 'applies commits to the tree'
|
|
143
|
+
case 'revert':
|
|
144
|
+
return args.some(a => DONE.test(a)) ? null : 'reverts commits in the tree'
|
|
145
|
+
case 'am':
|
|
146
|
+
return args.some(a => DONE.test(a) || a === '--show-current-patch' || a === '--retry') ? null : 'applies patches to the tree'
|
|
147
|
+
case 'apply': {
|
|
148
|
+
const o = parseArgs(args, { valuedLong: ['directory', 'exclude', 'include', 'whitespace', 'reject'] })
|
|
149
|
+
// --cached works on the index only; --check and the stat options write nothing
|
|
150
|
+
const readOnly = ['cached', 'check', 'stat', 'numstat', 'summary'].some(n => o.long.has(n))
|
|
151
|
+
return readOnly && !o.long.has('index') ? null : 'applies a patch to the tree'
|
|
152
|
+
}
|
|
153
|
+
case 'checkout-index': {
|
|
154
|
+
const o = parseArgs(args)
|
|
155
|
+
// --prefix writes the copy somewhere else
|
|
156
|
+
if (o.long.has('prefix')) return null
|
|
157
|
+
return (o.short.has('a') || o.long.has('all')) && (o.short.has('f') || o.long.has('force')) ? 'overwrites the files from the index' : null
|
|
158
|
+
}
|
|
159
|
+
case 'read-tree': {
|
|
160
|
+
const o = parseArgs(args)
|
|
161
|
+
return o.short.has('u') && (o.long.has('reset') || o.short.has('m')) ? 'rewrites the files' : null
|
|
162
|
+
}
|
|
163
|
+
case 'submodule': {
|
|
164
|
+
const o = parseArgs(args.slice(1))
|
|
165
|
+
return args[0] === 'update' && (o.short.has('f') || o.long.has('force')) ? 'resets the submodule files' : null
|
|
166
|
+
}
|
|
167
|
+
case 'rm': {
|
|
168
|
+
const o = parseArgs(args)
|
|
169
|
+
if (o.short.has('n') || o.long.has('dry-run') || o.long.has('cached')) return null
|
|
170
|
+
// a folder, or the whole tree; named files and narrow globs (sample-*.jpg) are an ordinary cleanup
|
|
171
|
+
return o.pos.some(p => wholeTree(p) || (o.short.has('r') && broad(p, dir, isDir) && !/[*?[]/.test(p.replace(/^.*\//, '')))) ? 'removes files' : null
|
|
172
|
+
}
|
|
69
173
|
// git add -A, git add ., git commit -a: everything changed in the tree goes into one commit,
|
|
70
|
-
// including the files the other session is in the middle of editing
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
174
|
+
// including the files the other session is in the middle of editing. A pathspec narrows it:
|
|
175
|
+
// git add -A src/ is the files in src/.
|
|
176
|
+
case 'add': {
|
|
177
|
+
const o = parseArgs(args, { valuedLong: ['pathspec-from-file'] })
|
|
178
|
+
if (o.short.has('n') || longIs(o, 'dry-run')) return null
|
|
179
|
+
const specs = o.pos.filter(p => !excluding(p))
|
|
180
|
+
if (specs.some(wholeTree)) return 'stage-all'
|
|
181
|
+
if (o.long.has('pathspec-from-file')) return null
|
|
182
|
+
const all = o.short.has('A') || o.short.has('u') || longIs(o, 'all') || longIs(o, 'update')
|
|
183
|
+
if (!specs.length) return all ? 'stage-all' : null
|
|
184
|
+
return specs.some(farPath) ? 'add-paths' : null
|
|
185
|
+
}
|
|
186
|
+
case 'commit': {
|
|
187
|
+
const o = parseArgs(args, { valuedShort: 'mFCct', valuedLong: ['message', 'file', 'reuse-message', 'reedit-message', 'fixup', 'squash', 'author', 'date', 'cleanup', 'template', 'trailer'] })
|
|
188
|
+
if (o.long.has('dry-run')) return null
|
|
189
|
+
return o.short.has('a') || longIs(o, 'all') ? 'stage-all' : null
|
|
190
|
+
}
|
|
75
191
|
default:
|
|
76
192
|
return null
|
|
77
193
|
}
|
|
@@ -90,6 +206,33 @@ function alive(pid) {
|
|
|
90
206
|
}
|
|
91
207
|
}
|
|
92
208
|
|
|
209
|
+
// When a process started, in ms, or 0 when it cannot be read.
|
|
210
|
+
function startOf(pid, platform = process.platform) {
|
|
211
|
+
try {
|
|
212
|
+
if (platform === 'win32') {
|
|
213
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().ToString('o')`],
|
|
214
|
+
{ encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
215
|
+
return Date.parse(out.trim()) || 0
|
|
216
|
+
}
|
|
217
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, LC_ALL: 'C' } })
|
|
218
|
+
return Date.parse(out.trim()) || 0
|
|
219
|
+
} catch {
|
|
220
|
+
return 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Is this session file's process still running? A file left behind by a session that died can
|
|
225
|
+
// name a pid another program now holds. The cheap check is that the pid exists; `deep` also
|
|
226
|
+
// compares the process start with the time the file says the session started, which costs a
|
|
227
|
+
// process lookup, so callers ask it only for the few sessions in the tree at hand. A start time
|
|
228
|
+
// that cannot be read counts as the same process.
|
|
229
|
+
function liveSession(pid, file, deep = false, startTime = startOf) {
|
|
230
|
+
if (!alive(pid)) return false
|
|
231
|
+
if (!deep || !file || !file.startedAt) return true
|
|
232
|
+
const started = startTime(pid)
|
|
233
|
+
return !started || Math.abs(started - file.startedAt) < PID_REUSE_SLACK_MS
|
|
234
|
+
}
|
|
235
|
+
|
|
93
236
|
// git prints the real path of the tree; a session may have started through a symlink, a junction
|
|
94
237
|
// or a Windows short name (C:\Users\RUNNER~1), so both sides are resolved before comparing.
|
|
95
238
|
const real = p => { try { return fs.realpathSync.native(p) } catch { return path.resolve(p) } }
|
|
@@ -97,6 +240,9 @@ const key = (p, platform) => {
|
|
|
97
240
|
const n = real(p).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
98
241
|
return platform === 'win32' ? n.toLowerCase() : n
|
|
99
242
|
}
|
|
243
|
+
// Loaded only when a guard blocks.
|
|
244
|
+
const describeSession = s => require('./session-info').describeSession(s)
|
|
245
|
+
|
|
100
246
|
const inside = (child, parent) => child === parent || child.startsWith(parent + '/')
|
|
101
247
|
|
|
102
248
|
// Other live sessions whose project directory is in the same working tree. A session started in
|
|
@@ -105,7 +251,7 @@ function sharers(top, sessionId, { files, isAlive, run, platform }) {
|
|
|
105
251
|
const topKey = key(top, platform)
|
|
106
252
|
const out = []
|
|
107
253
|
for (const [pid, f] of files) {
|
|
108
|
-
if (!f.cwd || f.sessionId === sessionId || !isAlive(pid)) continue
|
|
254
|
+
if (!f.cwd || f.sessionId === sessionId || !isAlive(pid, f, false)) continue
|
|
109
255
|
const cwdKey = key(f.cwd, platform)
|
|
110
256
|
if (!inside(cwdKey, topKey)) continue
|
|
111
257
|
if (cwdKey !== topKey) {
|
|
@@ -113,31 +259,52 @@ function sharers(top, sessionId, { files, isAlive, run, platform }) {
|
|
|
113
259
|
try { theirs = run(['rev-parse', '--show-toplevel'], f.cwd) } catch { continue }
|
|
114
260
|
if (key(theirs, platform) !== topKey) continue
|
|
115
261
|
}
|
|
262
|
+
if (!isAlive(pid, f, true)) continue
|
|
116
263
|
out.push({ pid, ...f })
|
|
117
264
|
}
|
|
118
265
|
return out
|
|
119
266
|
}
|
|
120
267
|
|
|
121
|
-
function when(ms) {
|
|
122
|
-
if (!ms) return 'unknown time'
|
|
123
|
-
const d = new Date(ms)
|
|
124
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
|
|
125
|
-
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
126
|
-
}
|
|
127
|
-
|
|
128
268
|
// git worktree remove <path> and rm -rf <path>: the folders the command deletes.
|
|
129
|
-
function removedDirs(c, cwd, home) {
|
|
269
|
+
function removedDirs(c, cwd, home, known = true) {
|
|
130
270
|
const call = gitCall(c.argv, cwd)
|
|
131
271
|
if (call && call.sub === 'worktree' && call.args[0] === 'remove') {
|
|
132
272
|
const rest = call.args.slice(1)
|
|
133
273
|
const target = rest.find(a => !a.startsWith('-'))
|
|
134
274
|
return target ? [{ dir: path.resolve(call.dir, expandHome(target, home)), how: 'git worktree remove', force: rest.some(a => a === '-f' || a === '--force') }] : []
|
|
135
275
|
}
|
|
136
|
-
return rmTargets(c.argv)
|
|
276
|
+
return [...rmTargets(c.argv), ...findDeletes(c.argv)]
|
|
137
277
|
.map(t => expandHome(t, home))
|
|
138
278
|
.map(t => expandPwd(t, cwd))
|
|
139
|
-
.
|
|
140
|
-
.
|
|
279
|
+
.map(t => deleted(t, cwd, known))
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// find <paths> -delete and find <paths> -exec rm: what they remove, when nothing narrows the
|
|
284
|
+
// files they reach (-name, -path, -mtime, -empty ...). find . -type f -delete is the whole tree.
|
|
285
|
+
const FIND_FILTER = /^-(?:i?name|i?path|i?wholename|i?regex|i?lname|newer\w*|[mac]time|[mac]min|empty|size|user|group|perm|samefile|inum|links|executable|readable|writable|used)$/
|
|
286
|
+
function findDeletes(t) {
|
|
287
|
+
if (shell.bin(t[0] || '') !== 'find') return []
|
|
288
|
+
let i = 1
|
|
289
|
+
while (/^-(?:[HLP]|O\d|D)$/.test(t[i] || '')) i++
|
|
290
|
+
const start = []
|
|
291
|
+
while (i < t.length && !/^(?:-|!$|\()/.test(t[i])) start.push(t[i++])
|
|
292
|
+
const expr = t.slice(i)
|
|
293
|
+
const removes = expr.includes('-delete') || expr.some((a, k) => /^-(?:exec|execdir|ok|okdir)$/.test(a) && /^(?:rm|unlink|shred|rmdir)$/.test(shell.bin(expr[k + 1] || '')))
|
|
294
|
+
return removes && !expr.some(a => FIND_FILTER.test(a)) ? (start.length ? start : ['.']) : []
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What one deletion target removes: the folder it names; every file in the folder for dir/*; the
|
|
298
|
+
// repository's whole folder for .git (without its history the folder is no longer a repository).
|
|
299
|
+
function deleted(t, cwd, known = true) {
|
|
300
|
+
// after a cd we cannot follow, a relative target means a folder we do not know
|
|
301
|
+
if (!t || (!known && !path.isAbsolute(t))) return null
|
|
302
|
+
const star = /^(?:(.*)[\\/])?\.?\*+$/.exec(t)
|
|
303
|
+
if (star && !/[$%*?`]/.test(star[1] || '')) return { dir: path.resolve(cwd, star[1] || '.'), how: 'deleting', force: true, what: 'everything in' }
|
|
304
|
+
if (/[$%*?`]/.test(t)) return null
|
|
305
|
+
const dir = path.resolve(cwd, t)
|
|
306
|
+
if (path.basename(dir) === '.git') return { dir: path.dirname(dir), how: 'deleting', force: true, what: 'the git data of' }
|
|
307
|
+
return { dir, how: 'deleting', force: true, what: '' }
|
|
141
308
|
}
|
|
142
309
|
|
|
143
310
|
// $PWD, ${PWD}, $(pwd) and `pwd` are the folder the command runs in.
|
|
@@ -171,17 +338,76 @@ function linksIn(dir) {
|
|
|
171
338
|
return out
|
|
172
339
|
}
|
|
173
340
|
|
|
341
|
+
// git ls-files -m | xargs git add: git add is fed every changed file. A list limited by a
|
|
342
|
+
// pathspec (git ls-files -m src) is not.
|
|
343
|
+
function fedByChangedList(all, c, call, text, cwd) {
|
|
344
|
+
if (c.depth !== 0 || !/(?:^|\s)xargs(?:\s|$)/.test(text.slice(c.s, c.e)) || parseArgs(call.args).pos.length) return false
|
|
345
|
+
return all.some(p => {
|
|
346
|
+
if (p === c || p.seg !== c.seg || p.depth !== 0 || p.s >= c.s) return false
|
|
347
|
+
const src = gitCall(p.argv, cwd)
|
|
348
|
+
if (!src) return false
|
|
349
|
+
const o = parseArgs(src.args)
|
|
350
|
+
if (o.after.length) return false
|
|
351
|
+
if (src.sub === 'ls-files') return !o.pos.length
|
|
352
|
+
if (src.sub === 'diff') return o.long.has('name-only') || o.long.has('name-status')
|
|
353
|
+
return src.sub === 'status' && (o.long.has('porcelain') || o.short.has('s') || o.long.has('short'))
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The paths in `git status --porcelain` output (the new name of a rename). The output has been
|
|
358
|
+
// trimmed, so the first line can have lost the space that starts its status.
|
|
359
|
+
function changedPaths(status) {
|
|
360
|
+
return status.split('\n').map(l => (/^[ MADRCTU?!]{1,2} (.+)$/.exec(l) || [])[1]).filter(Boolean).map(f => f.split(' -> ').pop().replace(/^"|"$/g, ''))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Does a pathspec (as typed in folder `dir`) cover a changed file (path from the tree top)?
|
|
364
|
+
function covers(spec, file, dir, top, platform) {
|
|
365
|
+
const abs = path.join(top, file)
|
|
366
|
+
if (/^:[^/(]/.test(spec) || /^:\((?!top\))/.test(spec)) return true
|
|
367
|
+
const top2 = /^:(?:\/|\(top\))/.test(spec)
|
|
368
|
+
const body = spec.replace(/^:(?:\/|\([^)]*\))?/, '')
|
|
369
|
+
const base = top2 ? top : dir
|
|
370
|
+
if (/[*?[]/.test(body)) {
|
|
371
|
+
const re = new RegExp('^' + path.resolve(base, body).replace(/\\/g, '/').replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
|
|
372
|
+
const sep = abs.replace(/\\/g, '/')
|
|
373
|
+
return sep.split('/').some((_, i, a) => re.test(a.slice(0, i + 1).join('/') || '/'))
|
|
374
|
+
}
|
|
375
|
+
return inside(key(abs, platform), key(path.resolve(base, body), platform))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The folder each command runs in: the hook's folder, moved by the cd commands before it. A cd
|
|
379
|
+
// to a folder we cannot work out (a variable, `cd -`, a folder that does not exist), or a ( cd x )
|
|
380
|
+
// subshell, makes the folder unknown: the command is then read from the hook's folder, but
|
|
381
|
+
// relative deletions are skipped.
|
|
382
|
+
function cwdsOf(all, text, cwd, home, folderExists = fs.existsSync) {
|
|
383
|
+
let cur = cwd
|
|
384
|
+
let known = !/\(\s*(?:cd|pushd)\b/.test(text)
|
|
385
|
+
return all.map(c => {
|
|
386
|
+
const here = { cwd: cur, known }
|
|
387
|
+
if (c.depth === 0 && known && /^(?:cd|pushd|chdir)$/.test(shell.bin(c.argv[0]))) {
|
|
388
|
+
const t = c.argv.slice(1).find(a => !a.startsWith('-') || a === '-')
|
|
389
|
+
const dest = t === undefined ? home : expandPwd(expandHome(t, home), cur)
|
|
390
|
+
const next = path.resolve(cur, dest)
|
|
391
|
+
// a folder that is not there yet (made by this very command) or a Git Bash style /c/... path on Windows
|
|
392
|
+
if (t === '-' || /[$`%]/.test(dest) || !folderExists(next)) known = false
|
|
393
|
+
else cur = next
|
|
394
|
+
}
|
|
395
|
+
return here
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
174
399
|
function classify(cmd, {
|
|
175
|
-
cwd = process.cwd(), sessionId = null, files = () => readSessionFiles(), isAlive =
|
|
176
|
-
home = os.homedir(), links = linksIn, removeRule = () => enabled('remove-session-dir'), stageRule = () => enabled('stage-all'),
|
|
400
|
+
cwd = process.cwd(), sessionId = null, files = () => readLiveSessionFiles({ files: readSessionFiles() }), isAlive = liveSession, run = git, platform = process.platform, mode = 'sh',
|
|
401
|
+
home = os.homedir(), links = linksIn, removeRule = () => enabled('remove-session-dir'), stageRule = () => enabled('stage-all'), isDir = dirOf, folderExists = fs.existsSync,
|
|
177
402
|
} = {}) {
|
|
178
403
|
const text = shell.prepare(String(cmd || ''), mode)
|
|
179
404
|
const all = shell.commands(text, mode).filter(c => !c.remote)
|
|
180
405
|
let live = null
|
|
181
|
-
const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid))))
|
|
406
|
+
const liveOthers = () => live || (live = new Map([...files()].filter(([pid, f]) => f.cwd && f.sessionId !== sessionId && isAlive(pid, f, false))))
|
|
182
407
|
|
|
183
|
-
|
|
184
|
-
|
|
408
|
+
const where = cwdsOf(all, text, cwd, home, folderExists)
|
|
409
|
+
for (const [i, c] of all.entries()) {
|
|
410
|
+
const removals = removedDirs(c, where[i].cwd, home, where[i].known)
|
|
185
411
|
if (!removals.length) continue
|
|
186
412
|
const seg = oneLine(c.argv.join(' '), 120)
|
|
187
413
|
for (const r of removals) {
|
|
@@ -201,12 +427,13 @@ function classify(cmd, {
|
|
|
201
427
|
if (!others.size) continue
|
|
202
428
|
const target = key(r.dir, platform)
|
|
203
429
|
// a deletion of the folder a session works in, or of a folder above it
|
|
204
|
-
const hit = [...others].find(([, f]) => inside(key(f.cwd, platform), target))
|
|
430
|
+
const hit = [...others].find(([pid, f]) => inside(key(f.cwd, platform), target) && isAlive(pid, f, true))
|
|
205
431
|
if (hit) {
|
|
206
432
|
const [pid, f] = hit
|
|
207
433
|
return {
|
|
208
434
|
rule: 'remove-session-dir',
|
|
209
|
-
|
|
435
|
+
owner: { pid, cwd: f.cwd },
|
|
436
|
+
why: `[bulkhead] checkout-guard: \`${seg}\` removes ${r.what ? r.what + ' ' : ''}${r.dir}, and another ${agentLabel(f.agent)} session works in it (${describeSession({ pid, cwd: f.cwd })}).\n` +
|
|
210
437
|
'Its files, its dev server and its next commit would disappear under it.\n' +
|
|
211
438
|
'Leave this folder to that session. If it is really finished, wait until it exits (`bulkhead status` lists the live sessions), then remove it.',
|
|
212
439
|
}
|
|
@@ -215,28 +442,66 @@ function classify(cmd, {
|
|
|
215
442
|
}
|
|
216
443
|
|
|
217
444
|
if (!sessionId) return null
|
|
218
|
-
for (const c of all) {
|
|
445
|
+
for (const [i, c] of all.entries()) {
|
|
219
446
|
if (shell.bin(c.argv[0]) !== 'git') continue
|
|
220
|
-
const call = gitCall(c.argv, cwd)
|
|
447
|
+
const call = gitCall(c.argv, where[i].cwd, c.depth === 0 ? gitEnv(text.slice(c.s, c.e)) : {})
|
|
221
448
|
const seg = c.argv.join(' ')
|
|
222
449
|
if (!call) continue
|
|
223
|
-
let what = changesTree(call)
|
|
224
|
-
if (!what
|
|
450
|
+
let what = changesTree(call, fs.existsSync, isDir)
|
|
451
|
+
if (!what && call.sub === 'add' && fedByChangedList(all, c, call, text, cwd)) what = 'stage-all'
|
|
452
|
+
const staging = what === 'stage-all' || what === 'add-paths'
|
|
453
|
+
if (!what || (staging && !stageRule())) continue
|
|
225
454
|
if (!liveOthers().size) continue
|
|
455
|
+
const gitRun = args => run([...call.opts, ...args], call.dir)
|
|
226
456
|
let top
|
|
227
|
-
try { top =
|
|
457
|
+
try { top = treeOf(call, run) } catch { continue }
|
|
228
458
|
const others = sharers(top, sessionId, { files: liveOthers(), isAlive, run, platform })
|
|
229
459
|
if (!others.length) continue
|
|
230
460
|
const o = others[0]
|
|
231
|
-
const who = `another
|
|
461
|
+
const who = `another ${agentLabel(o.agent)} session works in the same tree (${describeSession(o)}` +
|
|
232
462
|
(others.length > 1 ? `, and ${others.length - 1} more` : '') + ')'
|
|
463
|
+
if (what === 'add-paths') {
|
|
464
|
+
// a pathspec such as /work/shop or .. can still be the whole tree
|
|
465
|
+
const topKey = key(top, platform)
|
|
466
|
+
const far = parseArgs(call.args).pos.filter(p => !excluding(p) && farPath(p))
|
|
467
|
+
if (!far.some(p => key(path.resolve(call.dir, expandHome(p, home)), platform) === topKey)) continue
|
|
468
|
+
what = 'stage-all'
|
|
469
|
+
}
|
|
470
|
+
if (what === 'restore-paths') {
|
|
471
|
+
let status = ''
|
|
472
|
+
try { status = gitRun(['status', '--porcelain']) } catch { continue }
|
|
473
|
+
const specs = restoreSpecs(call, fs.existsSync)
|
|
474
|
+
const lost = changedPaths(status).filter(f => specs.some(sp => covers(sp, f, call.dir, top, platform)))
|
|
475
|
+
if (!lost.length) continue
|
|
476
|
+
return {
|
|
477
|
+
rule: `git-${call.sub}`,
|
|
478
|
+
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` overwrites ${lost.length} file${lost.length === 1 ? '' : 's'} with uncommitted changes in ${top} (${lost.slice(0, 3).join(', ')}${lost.length > 3 ? ', ...' : ''}), ` +
|
|
479
|
+
`and ${who}.\n` +
|
|
480
|
+
'Some of those changes are probably its work, and they would be lost.\n' +
|
|
481
|
+
'Restore the single files you changed, by name: git restore src/a.ts, or leave the folder alone.',
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (what === 'unstages') {
|
|
485
|
+
let out = ''
|
|
486
|
+
try { out = gitRun(['diff', '--cached', '--name-only']) } catch { continue }
|
|
487
|
+
const names = out.split('\n').filter(Boolean)
|
|
488
|
+
if (!names.length) continue
|
|
489
|
+
return {
|
|
490
|
+
rule: 'git-reset',
|
|
491
|
+
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` unstages ${names.length} file${names.length === 1 ? '' : 's'} in ${top} (${names.slice(0, 3).join(', ')}${names.length > 3 ? ', ...' : ''}), ` +
|
|
492
|
+
`and ${who}.\n` +
|
|
493
|
+
'That staged work is probably its own, and it would have to stage it again.\n' +
|
|
494
|
+
'Unstage only your files, by name: git reset -- src/a.ts (or git restore --staged src/a.ts).',
|
|
495
|
+
}
|
|
496
|
+
}
|
|
233
497
|
if (what === 'stage-all') {
|
|
234
498
|
let status = ''
|
|
235
|
-
try { status =
|
|
499
|
+
try { status = gitRun(['status', '--porcelain']) } catch { continue }
|
|
236
500
|
const changed = status.split('\n').filter(Boolean)
|
|
237
501
|
if (!changed.length) continue
|
|
238
502
|
return {
|
|
239
503
|
rule: 'stage-all',
|
|
504
|
+
owner: { pid: o.pid, cwd: o.cwd },
|
|
240
505
|
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` stages every change in ${top} (${changed.length} file${changed.length === 1 ? '' : 's'}), ` +
|
|
241
506
|
`and ${who}.\n` +
|
|
242
507
|
'Some of those changes are probably its half-finished work, and they would go into your commit.\n' +
|
|
@@ -245,7 +510,7 @@ function classify(cmd, {
|
|
|
245
510
|
}
|
|
246
511
|
if (what === 'dirty-only') {
|
|
247
512
|
let status = ''
|
|
248
|
-
try { status =
|
|
513
|
+
try { status = gitRun(['status', '--porcelain', '--untracked-files=no']) } catch { continue }
|
|
249
514
|
if (!status) continue
|
|
250
515
|
what = 'merges into a tree with uncommitted changes'
|
|
251
516
|
}
|
|
@@ -253,6 +518,7 @@ function classify(cmd, {
|
|
|
253
518
|
if (call.sub === 'pull') {
|
|
254
519
|
return {
|
|
255
520
|
rule: 'git-pull',
|
|
521
|
+
owner: { pid: o.pid, cwd: o.cwd },
|
|
256
522
|
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
|
|
257
523
|
'A pull rewrites the files that session has open, and a conflict would stop halfway through its edits.\n' +
|
|
258
524
|
'Wait until it has committed or stashed its changes, or pull from a clone no other session is using.',
|
|
@@ -260,9 +526,11 @@ function classify(cmd, {
|
|
|
260
526
|
}
|
|
261
527
|
return {
|
|
262
528
|
rule: `git-${call.sub}`,
|
|
529
|
+
owner: { pid: o.pid, cwd: o.cwd },
|
|
263
530
|
why: `[bulkhead] checkout-guard: \`${oneLine(seg, 120)}\` ${what} in ${top}, and ${who}.\n` +
|
|
264
531
|
'Its files and branch would change under it. Work in your own worktree instead:\n' +
|
|
265
|
-
` git worktree add ../${name}-<task> -b <branch
|
|
532
|
+
` git worktree add ../${name}-<task> -b <branch>\n` +
|
|
533
|
+
'or in one step, with .env files, dependencies and a free port: bulkhead worktree add <name>',
|
|
266
534
|
}
|
|
267
535
|
}
|
|
268
536
|
return null
|
|
@@ -273,16 +541,16 @@ function check(cmd, opts) {
|
|
|
273
541
|
return hit ? hit.why : null
|
|
274
542
|
}
|
|
275
543
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return blocked('checkout-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
|
|
283
|
-
})
|
|
544
|
+
function handle(input) {
|
|
545
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
546
|
+
if (!cmd) return
|
|
547
|
+
const hit = classify(cmd, { cwd: input.cwd || process.cwd(), sessionId: input.session_id || null, mode: shell.modeFor(input) })
|
|
548
|
+
if (!hit) return
|
|
549
|
+
return blocked('checkout-guard', hit.rule, input, `${hit.why}\n${ASK_USER}`)
|
|
284
550
|
}
|
|
285
551
|
|
|
552
|
+
if (require.main === module) runHook('checkout-guard', handle)
|
|
553
|
+
|
|
286
554
|
// `git-checkout`, `git-stash` and so on: the first git command that moves the tree.
|
|
287
555
|
function ruleOf(cmd, cwd = '.', mode = 'sh') {
|
|
288
556
|
for (const c of gitCommands(cmd, mode)) {
|
|
@@ -293,4 +561,4 @@ function ruleOf(cmd, cwd = '.', mode = 'sh') {
|
|
|
293
561
|
return 'git'
|
|
294
562
|
}
|
|
295
563
|
|
|
296
|
-
module.exports = { check, classify, changesTree, gitCall, ruleOf, linksIn }
|
|
564
|
+
module.exports = { handle, check, classify, changesTree, gitCall, ruleOf, linksIn, liveSession }
|
package/src/cli-util.js
CHANGED
|
@@ -27,6 +27,18 @@ function option(args, name) {
|
|
|
27
27
|
return undefined
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// A number option, `--name N` or `--name=N`: { value } (the fallback when the option is absent)
|
|
31
|
+
// or { error } with the message to print. Values at or below `min` are refused unless `inclusive`.
|
|
32
|
+
function numberOption(args, name, { fallback, min = 0, inclusive = false, max = Infinity, tooBig } = {}) {
|
|
33
|
+
const raw = option(args, name)
|
|
34
|
+
if (raw === undefined) return { value: fallback }
|
|
35
|
+
const n = raw === true || String(raw).trim() === '' ? NaN : Number(raw)
|
|
36
|
+
if (!Number.isFinite(n)) return { error: `--${name} needs a number, got ${raw === true ? 'nothing' : raw}` }
|
|
37
|
+
if (n < min || (n === min && !inclusive)) return { error: `--${name} must be ${inclusive ? 'at least' : 'above'} ${min}, got ${raw}` }
|
|
38
|
+
if (n > max) return { error: tooBig ? tooBig(n) : `--${name} must be at most ${max}, got ${raw}` }
|
|
39
|
+
return { value: n }
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
// Cut to `width` visible characters, ending in … when anything was dropped.
|
|
31
43
|
function fit(s, width) {
|
|
32
44
|
s = String(s == null ? '' : s)
|
|
@@ -76,4 +88,4 @@ function rejectArgs(cmd, args, spec, stderr, usage) {
|
|
|
76
88
|
return true
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, fit, table, bytes }
|
|
91
|
+
module.exports = { unknownArg, rejectArgs, useColor, paint, shortPath, option, numberOption, fit, table, bytes }
|