@paths.design/caws-cli 9.3.0 → 9.3.2
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/dist/commands/verify-acs.js +5 -5
- package/dist/commands/worktree.js +58 -5
- package/dist/index.js +7 -0
- package/dist/templates/.claude/hooks/worktree-guard.sh +34 -21
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +5 -4
- package/dist/utils/detection.js +13 -12
- package/dist/worktree/worktree-manager.js +266 -25
- package/package.json +1 -1
- package/templates/.claude/hooks/worktree-guard.sh +34 -21
- package/templates/.claude/hooks/worktree-write-guard.sh +5 -4
|
@@ -39,7 +39,7 @@ function detectTestRunner(projectRoot) {
|
|
|
39
39
|
if (fs.existsSync(fp)) {
|
|
40
40
|
try {
|
|
41
41
|
if (fs.readFileSync(fp, 'utf8').includes(needle)) return check.runner;
|
|
42
|
-
} catch (_) {}
|
|
42
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -123,7 +123,7 @@ function runNodeid(nodeid, runner, projectRoot) {
|
|
|
123
123
|
try {
|
|
124
124
|
switch (runner) {
|
|
125
125
|
case 'pytest': {
|
|
126
|
-
|
|
126
|
+
execFileSync('python3', ['-m', 'pytest', '-x', '--tb=short', nodeid], {
|
|
127
127
|
cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
128
|
});
|
|
129
129
|
return { passed: true, detail: 'tests passed' };
|
|
@@ -215,7 +215,7 @@ function checkEvidence(evidence, projectRoot) {
|
|
|
215
215
|
if (files) {
|
|
216
216
|
return { found: true, detail: `found: ${files.split('\n')[0]}` };
|
|
217
217
|
}
|
|
218
|
-
} catch (_) {}
|
|
218
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -350,7 +350,7 @@ function loadSpecs(projectRoot, targetSpecId) {
|
|
|
350
350
|
if (s && !TERMINAL_STATUSES.has(s.status)) {
|
|
351
351
|
specs.push({ path: workingSpec, spec: s });
|
|
352
352
|
}
|
|
353
|
-
} catch (_) {}
|
|
353
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
if (fs.existsSync(specsDir)) {
|
|
@@ -360,7 +360,7 @@ function loadSpecs(projectRoot, targetSpecId) {
|
|
|
360
360
|
if (s && !TERMINAL_STATUSES.has(s.status)) {
|
|
361
361
|
specs.push({ path: path.join(specsDir, f), spec: s });
|
|
362
362
|
}
|
|
363
|
-
} catch (_) {}
|
|
363
|
+
} catch (_) { /* ignore unreadable config */ }
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
366
|
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
destroyWorktree,
|
|
12
12
|
mergeWorktree,
|
|
13
13
|
pruneWorktrees,
|
|
14
|
+
repairWorktrees,
|
|
14
15
|
} = require('../worktree/worktree-manager');
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -31,9 +32,11 @@ async function worktreeCommand(subcommand, options = {}) {
|
|
|
31
32
|
return handleMerge(options);
|
|
32
33
|
case 'prune':
|
|
33
34
|
return handlePrune(options);
|
|
35
|
+
case 'repair':
|
|
36
|
+
return handleRepair(options);
|
|
34
37
|
default:
|
|
35
38
|
console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
|
|
36
|
-
console.log(chalk.blue('Available: create, list, destroy, merge, prune'));
|
|
39
|
+
console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair'));
|
|
37
40
|
process.exit(1);
|
|
38
41
|
}
|
|
39
42
|
} catch (error) {
|
|
@@ -72,18 +75,20 @@ function handleList() {
|
|
|
72
75
|
return;
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
const maxNameLen = Math.max(18, ...entries.map((e) => e.name.length + 2));
|
|
79
|
+
const totalWidth = maxNameLen + 12 + 20 + 16 + 10;
|
|
75
80
|
console.log(chalk.bold.cyan('CAWS Worktrees'));
|
|
76
|
-
console.log(chalk.cyan('='.repeat(
|
|
81
|
+
console.log(chalk.cyan('='.repeat(totalWidth)));
|
|
77
82
|
console.log(
|
|
78
83
|
chalk.bold(
|
|
79
|
-
'Name'.padEnd(
|
|
84
|
+
'Name'.padEnd(maxNameLen) +
|
|
80
85
|
'Status'.padEnd(12) +
|
|
81
86
|
'Branch'.padEnd(20) +
|
|
82
87
|
'Last Commit'.padEnd(16) +
|
|
83
88
|
'Owner'
|
|
84
89
|
)
|
|
85
90
|
);
|
|
86
|
-
console.log(chalk.gray('-'.repeat(
|
|
91
|
+
console.log(chalk.gray('-'.repeat(totalWidth)));
|
|
87
92
|
|
|
88
93
|
for (const entry of entries) {
|
|
89
94
|
const statusColor =
|
|
@@ -114,7 +119,7 @@ function handleList() {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
console.log(
|
|
117
|
-
entry.name.padEnd(
|
|
122
|
+
entry.name.padEnd(maxNameLen) +
|
|
118
123
|
statusColor(statusStr.padEnd(12)) +
|
|
119
124
|
(entry.branch || '').padEnd(20) +
|
|
120
125
|
commitAge.padEnd(16 + 10) + // +10 for chalk color codes
|
|
@@ -225,4 +230,52 @@ function handlePrune(options) {
|
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
|
|
234
|
+
function handleRepair(options) {
|
|
235
|
+
const dryRun = options.dryRun || false;
|
|
236
|
+
const shouldPrune = options.prune || false;
|
|
237
|
+
|
|
238
|
+
if (dryRun) {
|
|
239
|
+
console.log(chalk.cyan('Repair dry-run (no changes will be persisted)'));
|
|
240
|
+
} else {
|
|
241
|
+
console.log(chalk.cyan('Repairing worktree registry'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = repairWorktrees({ prune: shouldPrune, dryRun });
|
|
245
|
+
|
|
246
|
+
if (result.repaired.length === 0 && result.pruned.length === 0 && result.skipped.length === 0) {
|
|
247
|
+
console.log(chalk.green('Registry is consistent. Nothing to repair.'));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (result.repaired.length > 0) {
|
|
252
|
+
console.log(chalk.green('\nRepaired ' + result.repaired.length + ' entry/entries:'));
|
|
253
|
+
for (const r of result.repaired) {
|
|
254
|
+
if (r.action === 'registered') {
|
|
255
|
+
console.log(chalk.gray(' + ' + r.name + ' (auto-registered from git)'));
|
|
256
|
+
} else {
|
|
257
|
+
console.log(chalk.gray(' ~ ' + r.name + ' (' + r.from + ' -> ' + r.to + ')'));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (result.pruned.length > 0) {
|
|
263
|
+
console.log(chalk.green('\nPruned ' + result.pruned.length + ' stale entry/entries:'));
|
|
264
|
+
for (const p of result.pruned) {
|
|
265
|
+
console.log(chalk.gray(' - ' + p.name + ' (' + p.status + ')'));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (result.skipped.length > 0) {
|
|
270
|
+
console.log(chalk.yellow('\nSkipped ' + result.skipped.length + ' entry/entries:'));
|
|
271
|
+
for (const s of result.skipped) {
|
|
272
|
+
console.log(chalk.yellow(' ? ' + s.name + ': ' + s.reason));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (dryRun) {
|
|
277
|
+
console.log(chalk.blue('\nDry-run complete. Run without --dry-run to persist changes.'));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
228
281
|
module.exports = { worktreeCommand };
|
package/dist/index.js
CHANGED
|
@@ -395,6 +395,13 @@ worktreeCmd
|
|
|
395
395
|
.option('--max-age <days>', 'Remove entries older than N days', '30')
|
|
396
396
|
.action((options) => worktreeCommand('prune', options));
|
|
397
397
|
|
|
398
|
+
worktreeCmd
|
|
399
|
+
.command('repair')
|
|
400
|
+
.description('Reconcile registry with git and filesystem state')
|
|
401
|
+
.option('--dry-run', 'Report only, do not persist changes', false)
|
|
402
|
+
.option('--prune', 'Remove destroyed and stale-merged entries', false)
|
|
403
|
+
.action((options) => worktreeCommand('repair', options));
|
|
404
|
+
|
|
398
405
|
// Session command group
|
|
399
406
|
const sessionCmd = program
|
|
400
407
|
.command('session')
|
|
@@ -11,6 +11,7 @@ INPUT=$(cat)
|
|
|
11
11
|
# Extract tool info
|
|
12
12
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
13
13
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
|
|
14
|
+
HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
14
15
|
|
|
15
16
|
# Only check Bash tool
|
|
16
17
|
if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
|
|
@@ -46,26 +47,38 @@ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--sc
|
|
|
46
47
|
fi
|
|
47
48
|
|
|
48
49
|
# --- Gap 5: Block cross-boundary file copies ---
|
|
50
|
+
# Only block copies FROM a worktree back to the main repo (defeats isolation).
|
|
51
|
+
# Copies INTO a worktree are fine — the agent is working there and the files
|
|
52
|
+
# live on the worktree branch, disappearing on merge.
|
|
49
53
|
WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
|
|
50
54
|
if [[ -d "$WORKTREE_BASE" ]]; then
|
|
51
55
|
if echo "$COMMAND" | grep -qE '\b(cp|mv)\b'; then
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if echo "$COMMAND" | grep -
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
echo "
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
# If the agent is working inside a worktree, allow all copies — they're
|
|
57
|
+
# in their own workspace
|
|
58
|
+
AGENT_IN_WORKTREE=false
|
|
59
|
+
if [[ -n "$HOOK_CWD" ]] && [[ "$HOOK_CWD" == "$WORKTREE_BASE"/* ]]; then
|
|
60
|
+
AGENT_IN_WORKTREE=true
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if [[ "$AGENT_IN_WORKTREE" != "true" ]]; then
|
|
64
|
+
if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
|
|
65
|
+
# Check if the command references both a worktree path and the main repo
|
|
66
|
+
HAS_WT_PATH=false
|
|
67
|
+
HAS_MAIN_PATH=false
|
|
68
|
+
if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
|
|
69
|
+
HAS_WT_PATH=true
|
|
70
|
+
fi
|
|
71
|
+
# Check if destination/source is outside the worktree
|
|
72
|
+
if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
|
|
73
|
+
HAS_MAIN_PATH=true
|
|
74
|
+
fi
|
|
75
|
+
if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
|
|
76
|
+
echo "BLOCKED: Copying files from a worktree to the main repo is forbidden." >&2
|
|
77
|
+
echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
|
|
78
|
+
echo "If tests need the main repo's venv, activate it with:" >&2
|
|
79
|
+
echo " source $PROJECT_DIR/.venv/bin/activate" >&2
|
|
80
|
+
exit 2
|
|
81
|
+
fi
|
|
69
82
|
fi
|
|
70
83
|
fi
|
|
71
84
|
fi
|
|
@@ -151,10 +164,10 @@ if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(--force|-f\s)'; then
|
|
|
151
164
|
fi
|
|
152
165
|
|
|
153
166
|
# --- Base branch protections ---
|
|
154
|
-
# Use the
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
167
|
+
# Use the hook input's cwd (where the git command will actually execute), not
|
|
168
|
+
# CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
|
|
169
|
+
# agent has cd'd into a worktree at .caws/worktrees/<name>/).
|
|
170
|
+
AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
158
171
|
CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
159
172
|
|
|
160
173
|
# Determine the base branch to protect
|
|
@@ -13,6 +13,7 @@ INPUT=$(cat)
|
|
|
13
13
|
# Extract tool info
|
|
14
14
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
15
15
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
|
|
16
|
+
HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
16
17
|
|
|
17
18
|
# Only check Write and Edit tools
|
|
18
19
|
case "$TOOL_NAME" in
|
|
@@ -42,10 +43,10 @@ if ! command -v node >/dev/null 2>&1; then
|
|
|
42
43
|
exit 0
|
|
43
44
|
fi
|
|
44
45
|
|
|
45
|
-
# Use the
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
46
|
+
# Use the hook input's cwd (where the agent is actually working), not
|
|
47
|
+
# CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
|
|
48
|
+
# agent has cd'd into a worktree at .caws/worktrees/<name>/).
|
|
49
|
+
AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
49
50
|
CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
50
51
|
|
|
51
52
|
WT_INFO=$(node -e "
|
package/dist/utils/detection.js
CHANGED
|
@@ -203,17 +203,8 @@ function detectCAWSSetup(cwd = process.cwd()) {
|
|
|
203
203
|
* @returns {string} Project root directory path
|
|
204
204
|
*/
|
|
205
205
|
function findProjectRoot(startDir = process.cwd()) {
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
const root = path.parse(dir).root;
|
|
209
|
-
while (dir !== root) {
|
|
210
|
-
if (fs.existsSync(path.join(dir, '.caws'))) {
|
|
211
|
-
return dir;
|
|
212
|
-
}
|
|
213
|
-
dir = path.dirname(dir);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Fallback: try git root
|
|
206
|
+
// In a monorepo, nested packages may have their own .caws/ (scaffold debris).
|
|
207
|
+
// The git root's .caws/ is authoritative — check it first.
|
|
217
208
|
try {
|
|
218
209
|
const { execFileSync } = require('child_process');
|
|
219
210
|
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
@@ -225,7 +216,17 @@ function findProjectRoot(startDir = process.cwd()) {
|
|
|
225
216
|
return gitRoot;
|
|
226
217
|
}
|
|
227
218
|
} catch {
|
|
228
|
-
// Not a git repo or git not available
|
|
219
|
+
// Not a git repo or git not available — fall through
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Walk up looking for .caws/ directory (non-git projects)
|
|
223
|
+
let dir = path.resolve(startDir);
|
|
224
|
+
const root = path.parse(dir).root;
|
|
225
|
+
while (dir !== root) {
|
|
226
|
+
if (fs.existsSync(path.join(dir, '.caws'))) {
|
|
227
|
+
return dir;
|
|
228
|
+
}
|
|
229
|
+
dir = path.dirname(dir);
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
// Final fallback: cwd
|
|
@@ -54,13 +54,25 @@ function isBranchMerged(branch, target, root) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Get the git repository root
|
|
58
|
-
*
|
|
57
|
+
* Get the canonical git repository root (main worktree, not a linked worktree).
|
|
58
|
+
*
|
|
59
|
+
* `git rev-parse --show-toplevel` returns the root of whichever worktree
|
|
60
|
+
* the CWD is inside. In a linked worktree that is NOT the main repo root,
|
|
61
|
+
* so CAWS would read the wrong (or missing) .caws/worktrees.json.
|
|
62
|
+
*
|
|
63
|
+
* `--git-common-dir` always resolves to the main repo's .git directory,
|
|
64
|
+
* even from inside a linked worktree. Its parent is the canonical repo root.
|
|
65
|
+
*
|
|
66
|
+
* @returns {string} Absolute path to the main repo root
|
|
59
67
|
*/
|
|
60
68
|
function getRepoRoot() {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
const gitCommonDir = execFileSync(
|
|
70
|
+
'git',
|
|
71
|
+
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
72
|
+
{ encoding: 'utf8' }
|
|
73
|
+
).trim();
|
|
74
|
+
// gitCommonDir is /path/to/main-repo/.git — parent is the repo root
|
|
75
|
+
return path.dirname(gitCommonDir);
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/**
|
|
@@ -101,6 +113,106 @@ function saveRegistry(root, registry) {
|
|
|
101
113
|
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
102
114
|
}
|
|
103
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Discover git worktrees under .caws/worktrees/ that are not in the registry.
|
|
118
|
+
* @param {string} root - Repository root
|
|
119
|
+
* @param {Object} registry - Current registry object
|
|
120
|
+
* @returns {Array<{ name: string, path: string, branch: string }>}
|
|
121
|
+
*/
|
|
122
|
+
function discoverUnregisteredWorktrees(root, registry) {
|
|
123
|
+
const unregistered = [];
|
|
124
|
+
try {
|
|
125
|
+
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
126
|
+
cwd: root,
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
stdio: 'pipe',
|
|
129
|
+
});
|
|
130
|
+
let worktreesDir;
|
|
131
|
+
try {
|
|
132
|
+
worktreesDir = fs.realpathSync(path.resolve(root, WORKTREES_DIR));
|
|
133
|
+
} catch {
|
|
134
|
+
// Directory might not exist yet
|
|
135
|
+
worktreesDir = path.resolve(root, WORKTREES_DIR);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const blocks = output.split('\n\n').filter(Boolean);
|
|
139
|
+
for (const block of blocks) {
|
|
140
|
+
const lines = block.split('\n');
|
|
141
|
+
const wtLine = lines.find((l) => l.startsWith('worktree '));
|
|
142
|
+
const branchLine = lines.find((l) => l.startsWith('branch '));
|
|
143
|
+
if (!wtLine) continue;
|
|
144
|
+
|
|
145
|
+
const wtPath = wtLine.replace('worktree ', '');
|
|
146
|
+
let resolvedPath;
|
|
147
|
+
try {
|
|
148
|
+
resolvedPath = fs.realpathSync(wtPath);
|
|
149
|
+
} catch {
|
|
150
|
+
resolvedPath = path.resolve(wtPath);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only consider worktrees under .caws/worktrees/
|
|
154
|
+
if (!resolvedPath.startsWith(worktreesDir + path.sep)) continue;
|
|
155
|
+
|
|
156
|
+
const name = path.basename(resolvedPath);
|
|
157
|
+
if (registry.worktrees[name]) continue;
|
|
158
|
+
|
|
159
|
+
const branch = branchLine
|
|
160
|
+
? branchLine.replace('branch refs/heads/', '')
|
|
161
|
+
: `${BRANCH_PREFIX}${name}`;
|
|
162
|
+
unregistered.push({ name, path: resolvedPath, branch });
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// git worktree list failed
|
|
166
|
+
}
|
|
167
|
+
return unregistered;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Auto-register an unregistered worktree. Infers baseBranch via merge-base.
|
|
172
|
+
* @param {string} root - Repository root
|
|
173
|
+
* @param {Object} registry - Registry object (mutated in place)
|
|
174
|
+
* @param {{ name: string, path: string, branch: string }} discovered
|
|
175
|
+
* @returns {Object} The registered entry
|
|
176
|
+
*/
|
|
177
|
+
function autoRegisterWorktree(root, registry, discovered) {
|
|
178
|
+
let baseBranch = 'main';
|
|
179
|
+
try {
|
|
180
|
+
execFileSync(
|
|
181
|
+
'git',
|
|
182
|
+
['merge-base', discovered.branch, 'main'],
|
|
183
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
184
|
+
);
|
|
185
|
+
} catch {
|
|
186
|
+
try {
|
|
187
|
+
execFileSync(
|
|
188
|
+
'git',
|
|
189
|
+
['merge-base', discovered.branch, 'master'],
|
|
190
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
191
|
+
);
|
|
192
|
+
baseBranch = 'master';
|
|
193
|
+
} catch {
|
|
194
|
+
// Keep 'main' as default
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entry = {
|
|
199
|
+
name: discovered.name,
|
|
200
|
+
path: discovered.path,
|
|
201
|
+
branch: discovered.branch,
|
|
202
|
+
baseBranch,
|
|
203
|
+
scope: null,
|
|
204
|
+
specId: null,
|
|
205
|
+
owner: null,
|
|
206
|
+
createdAt: new Date().toISOString(),
|
|
207
|
+
status: 'active',
|
|
208
|
+
autoRegistered: true,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
registry.worktrees[discovered.name] = entry;
|
|
212
|
+
saveRegistry(root, registry);
|
|
213
|
+
return entry;
|
|
214
|
+
}
|
|
215
|
+
|
|
104
216
|
/**
|
|
105
217
|
* Create a new git worktree with scope isolation
|
|
106
218
|
* @param {string} name - Worktree name
|
|
@@ -258,19 +370,31 @@ function createWorktree(name, options = {}) {
|
|
|
258
370
|
}
|
|
259
371
|
|
|
260
372
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
373
|
+
* Reconcile registry state against git worktree list and filesystem.
|
|
374
|
+
*
|
|
375
|
+
* Non-destructive read that classifies every known worktree entry
|
|
376
|
+
* (from registry + git discovery) into one of:
|
|
377
|
+
* active — directory exists AND in git worktree list
|
|
378
|
+
* orphaned — directory exists but NOT in git worktree list
|
|
379
|
+
* missing — directory gone, branch may or may not exist
|
|
380
|
+
* destroyed — explicitly destroyed via CAWS
|
|
381
|
+
* unregistered — in git worktree list but not in registry
|
|
382
|
+
* stale-merged — missing + branch already merged to base
|
|
383
|
+
*
|
|
384
|
+
* Does NOT mutate the registry. Callers decide what to persist.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} root - Repository root
|
|
387
|
+
* @returns {{ entries: Array, gitWorktrees: string[] }}
|
|
263
388
|
*/
|
|
264
|
-
function
|
|
265
|
-
const root = getRepoRoot();
|
|
389
|
+
function reconcileRegistry(root) {
|
|
266
390
|
const registry = loadRegistry(root);
|
|
267
391
|
|
|
268
|
-
// Get actual git worktrees for validation
|
|
269
392
|
let gitWorktrees = [];
|
|
270
393
|
try {
|
|
271
394
|
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
272
395
|
cwd: root,
|
|
273
396
|
encoding: 'utf8',
|
|
397
|
+
stdio: 'pipe',
|
|
274
398
|
});
|
|
275
399
|
gitWorktrees = output
|
|
276
400
|
.split('\n\n')
|
|
@@ -290,24 +414,121 @@ function listWorktrees() {
|
|
|
290
414
|
const inGit = gitWorktrees.some(
|
|
291
415
|
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
292
416
|
);
|
|
293
|
-
const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
|
|
294
417
|
|
|
295
|
-
|
|
296
|
-
|
|
418
|
+
let status;
|
|
419
|
+
if (entry.status === 'destroyed') {
|
|
420
|
+
status = 'destroyed';
|
|
421
|
+
} else if (exists && inGit) {
|
|
422
|
+
status = 'active';
|
|
423
|
+
} else if (exists) {
|
|
424
|
+
status = 'orphaned';
|
|
425
|
+
} else {
|
|
426
|
+
const merged = entry.branch && entry.baseBranch
|
|
427
|
+
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
428
|
+
: false;
|
|
429
|
+
status = merged ? 'stale-merged' : 'missing';
|
|
430
|
+
}
|
|
297
431
|
|
|
298
|
-
|
|
432
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
299
433
|
const merged = entry.branch && entry.baseBranch
|
|
300
434
|
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
301
435
|
: false;
|
|
302
436
|
|
|
303
|
-
return {
|
|
304
|
-
...entry,
|
|
305
|
-
status,
|
|
306
|
-
lastCommit,
|
|
307
|
-
merged,
|
|
308
|
-
};
|
|
437
|
+
return { ...entry, status, lastCommit, merged };
|
|
309
438
|
});
|
|
310
439
|
|
|
440
|
+
// Append unregistered worktrees discovered from git
|
|
441
|
+
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
442
|
+
for (const discovered of unregistered) {
|
|
443
|
+
const lastCommit = getLastCommitInfo(discovered.branch, root);
|
|
444
|
+
entries.push({
|
|
445
|
+
name: discovered.name,
|
|
446
|
+
path: discovered.path,
|
|
447
|
+
branch: discovered.branch,
|
|
448
|
+
baseBranch: null,
|
|
449
|
+
scope: null,
|
|
450
|
+
specId: null,
|
|
451
|
+
owner: null,
|
|
452
|
+
createdAt: null,
|
|
453
|
+
status: 'unregistered',
|
|
454
|
+
lastCommit,
|
|
455
|
+
merged: false,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { entries, gitWorktrees };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Repair registry drift caused by manual git operations outside CAWS.
|
|
464
|
+
*
|
|
465
|
+
* Scans registry vs git vs filesystem, classifies each entry, and optionally
|
|
466
|
+
* prunes stale entries. Reports the delta before persisting.
|
|
467
|
+
*
|
|
468
|
+
* @param {Object} options
|
|
469
|
+
* @param {boolean} [options.prune=false] - Remove destroyed and stale-merged entries
|
|
470
|
+
* @param {boolean} [options.dryRun=false] - Report only, do not persist
|
|
471
|
+
* @returns {{ repaired: Array, pruned: Array, skipped: Array }}
|
|
472
|
+
*/
|
|
473
|
+
function repairWorktrees(options = {}) {
|
|
474
|
+
const { prune: shouldPrune = false, dryRun = false } = options;
|
|
475
|
+
const root = getRepoRoot();
|
|
476
|
+
const registry = loadRegistry(root);
|
|
477
|
+
const { entries } = reconcileRegistry(root);
|
|
478
|
+
|
|
479
|
+
const repaired = [];
|
|
480
|
+
const pruned = [];
|
|
481
|
+
const skipped = [];
|
|
482
|
+
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
const regEntry = registry.worktrees[entry.name];
|
|
485
|
+
|
|
486
|
+
if (entry.status === 'unregistered') {
|
|
487
|
+
if (!dryRun) {
|
|
488
|
+
autoRegisterWorktree(root, registry, entry);
|
|
489
|
+
}
|
|
490
|
+
repaired.push({ name: entry.name, action: 'registered', status: entry.status });
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!regEntry) continue;
|
|
495
|
+
|
|
496
|
+
// Update registry status to match filesystem reality
|
|
497
|
+
if (regEntry.status === 'active' && (entry.status === 'missing' || entry.status === 'stale-merged')) {
|
|
498
|
+
repaired.push({ name: entry.name, action: 'status-updated', from: 'active', to: entry.status });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Prune if requested and entry is dead
|
|
502
|
+
if (shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
|
|
503
|
+
if (!dryRun) {
|
|
504
|
+
delete registry.worktrees[entry.name];
|
|
505
|
+
}
|
|
506
|
+
pruned.push({ name: entry.name, status: entry.status });
|
|
507
|
+
} else if (!shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
|
|
508
|
+
skipped.push({ name: entry.name, reason: entry.status + ' (use --prune to remove)' });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!dryRun) {
|
|
513
|
+
saveRegistry(root, registry);
|
|
514
|
+
try {
|
|
515
|
+
execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
|
|
516
|
+
} catch {
|
|
517
|
+
// Non-fatal
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return { repaired, pruned, skipped };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* List all registered worktrees with filesystem validation.
|
|
526
|
+
* Delegates to reconcileRegistry() for state classification.
|
|
527
|
+
* @returns {Array} Worktree entries with status
|
|
528
|
+
*/
|
|
529
|
+
function listWorktrees() {
|
|
530
|
+
const root = getRepoRoot();
|
|
531
|
+
const { entries } = reconcileRegistry(root);
|
|
311
532
|
return entries;
|
|
312
533
|
}
|
|
313
534
|
|
|
@@ -323,9 +544,17 @@ function destroyWorktree(name, options = {}) {
|
|
|
323
544
|
const registry = loadRegistry(root);
|
|
324
545
|
const { deleteBranch = false, force = false } = options;
|
|
325
546
|
|
|
326
|
-
|
|
547
|
+
let entry = registry.worktrees[name];
|
|
327
548
|
if (!entry) {
|
|
328
|
-
|
|
549
|
+
// Fallback: scan git for unregistered worktree and auto-register
|
|
550
|
+
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
551
|
+
const discovered = unregistered.find((u) => u.name === name);
|
|
552
|
+
if (discovered) {
|
|
553
|
+
console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
|
|
554
|
+
entry = autoRegisterWorktree(root, registry, discovered);
|
|
555
|
+
} else {
|
|
556
|
+
throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
|
|
557
|
+
}
|
|
329
558
|
}
|
|
330
559
|
|
|
331
560
|
// Ownership check: refuse to destroy another agent's active worktree without --force
|
|
@@ -438,9 +667,17 @@ function mergeWorktree(name, options = {}) {
|
|
|
438
667
|
const registry = loadRegistry(root);
|
|
439
668
|
const { dryRun = false, deleteBranch = true, message } = options;
|
|
440
669
|
|
|
441
|
-
|
|
670
|
+
let entry = registry.worktrees[name];
|
|
442
671
|
if (!entry) {
|
|
443
|
-
|
|
672
|
+
// Fallback: scan git for unregistered worktree and auto-register
|
|
673
|
+
const unregistered = discoverUnregisteredWorktrees(root, registry);
|
|
674
|
+
const discovered = unregistered.find((u) => u.name === name);
|
|
675
|
+
if (discovered) {
|
|
676
|
+
console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
|
|
677
|
+
entry = autoRegisterWorktree(root, registry, discovered);
|
|
678
|
+
} else {
|
|
679
|
+
throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
|
|
680
|
+
}
|
|
444
681
|
}
|
|
445
682
|
|
|
446
683
|
const baseBranch = entry.baseBranch || 'main';
|
|
@@ -469,7 +706,7 @@ function mergeWorktree(name, options = {}) {
|
|
|
469
706
|
let conflicts = [];
|
|
470
707
|
try {
|
|
471
708
|
// New-style merge-tree: takes two branches, computes merge-base automatically
|
|
472
|
-
|
|
709
|
+
execFileSync(
|
|
473
710
|
'git',
|
|
474
711
|
['merge-tree', '--write-tree', baseBranch, entry.branch],
|
|
475
712
|
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
@@ -620,10 +857,14 @@ module.exports = {
|
|
|
620
857
|
destroyWorktree,
|
|
621
858
|
mergeWorktree,
|
|
622
859
|
pruneWorktrees,
|
|
860
|
+
repairWorktrees,
|
|
861
|
+
reconcileRegistry,
|
|
623
862
|
loadRegistry,
|
|
624
863
|
getRepoRoot,
|
|
625
864
|
getLastCommitInfo,
|
|
626
865
|
isBranchMerged,
|
|
866
|
+
discoverUnregisteredWorktrees,
|
|
867
|
+
autoRegisterWorktree,
|
|
627
868
|
WORKTREES_DIR,
|
|
628
869
|
REGISTRY_FILE,
|
|
629
870
|
BRANCH_PREFIX,
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ INPUT=$(cat)
|
|
|
11
11
|
# Extract tool info
|
|
12
12
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
13
13
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
|
|
14
|
+
HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
14
15
|
|
|
15
16
|
# Only check Bash tool
|
|
16
17
|
if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
|
|
@@ -46,26 +47,38 @@ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--sc
|
|
|
46
47
|
fi
|
|
47
48
|
|
|
48
49
|
# --- Gap 5: Block cross-boundary file copies ---
|
|
50
|
+
# Only block copies FROM a worktree back to the main repo (defeats isolation).
|
|
51
|
+
# Copies INTO a worktree are fine — the agent is working there and the files
|
|
52
|
+
# live on the worktree branch, disappearing on merge.
|
|
49
53
|
WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
|
|
50
54
|
if [[ -d "$WORKTREE_BASE" ]]; then
|
|
51
55
|
if echo "$COMMAND" | grep -qE '\b(cp|mv)\b'; then
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if echo "$COMMAND" | grep -
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
echo "
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
# If the agent is working inside a worktree, allow all copies — they're
|
|
57
|
+
# in their own workspace
|
|
58
|
+
AGENT_IN_WORKTREE=false
|
|
59
|
+
if [[ -n "$HOOK_CWD" ]] && [[ "$HOOK_CWD" == "$WORKTREE_BASE"/* ]]; then
|
|
60
|
+
AGENT_IN_WORKTREE=true
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if [[ "$AGENT_IN_WORKTREE" != "true" ]]; then
|
|
64
|
+
if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
|
|
65
|
+
# Check if the command references both a worktree path and the main repo
|
|
66
|
+
HAS_WT_PATH=false
|
|
67
|
+
HAS_MAIN_PATH=false
|
|
68
|
+
if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
|
|
69
|
+
HAS_WT_PATH=true
|
|
70
|
+
fi
|
|
71
|
+
# Check if destination/source is outside the worktree
|
|
72
|
+
if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
|
|
73
|
+
HAS_MAIN_PATH=true
|
|
74
|
+
fi
|
|
75
|
+
if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
|
|
76
|
+
echo "BLOCKED: Copying files from a worktree to the main repo is forbidden." >&2
|
|
77
|
+
echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
|
|
78
|
+
echo "If tests need the main repo's venv, activate it with:" >&2
|
|
79
|
+
echo " source $PROJECT_DIR/.venv/bin/activate" >&2
|
|
80
|
+
exit 2
|
|
81
|
+
fi
|
|
69
82
|
fi
|
|
70
83
|
fi
|
|
71
84
|
fi
|
|
@@ -151,10 +164,10 @@ if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(--force|-f\s)'; then
|
|
|
151
164
|
fi
|
|
152
165
|
|
|
153
166
|
# --- Base branch protections ---
|
|
154
|
-
# Use the
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
167
|
+
# Use the hook input's cwd (where the git command will actually execute), not
|
|
168
|
+
# CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
|
|
169
|
+
# agent has cd'd into a worktree at .caws/worktrees/<name>/).
|
|
170
|
+
AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
158
171
|
CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
159
172
|
|
|
160
173
|
# Determine the base branch to protect
|
|
@@ -13,6 +13,7 @@ INPUT=$(cat)
|
|
|
13
13
|
# Extract tool info
|
|
14
14
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
|
|
15
15
|
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
|
|
16
|
+
HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
|
|
16
17
|
|
|
17
18
|
# Only check Write and Edit tools
|
|
18
19
|
case "$TOOL_NAME" in
|
|
@@ -42,10 +43,10 @@ if ! command -v node >/dev/null 2>&1; then
|
|
|
42
43
|
exit 0
|
|
43
44
|
fi
|
|
44
45
|
|
|
45
|
-
# Use the
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
|
|
46
|
+
# Use the hook input's cwd (where the agent is actually working), not
|
|
47
|
+
# CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
|
|
48
|
+
# agent has cd'd into a worktree at .caws/worktrees/<name>/).
|
|
49
|
+
AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
|
|
49
50
|
CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
50
51
|
|
|
51
52
|
WT_INFO=$(node -e "
|