@paths.design/caws-cli 9.3.1 → 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/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/worktree/worktree-manager.js +124 -20
- package/package.json +1 -1
- package/templates/.claude/hooks/worktree-guard.sh +34 -21
- package/templates/.claude/hooks/worktree-write-guard.sh +5 -4
|
@@ -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 "
|
|
@@ -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
|
/**
|
|
@@ -358,19 +370,31 @@ function createWorktree(name, options = {}) {
|
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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[] }}
|
|
363
388
|
*/
|
|
364
|
-
function
|
|
365
|
-
const root = getRepoRoot();
|
|
389
|
+
function reconcileRegistry(root) {
|
|
366
390
|
const registry = loadRegistry(root);
|
|
367
391
|
|
|
368
|
-
// Get actual git worktrees for validation
|
|
369
392
|
let gitWorktrees = [];
|
|
370
393
|
try {
|
|
371
394
|
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
372
395
|
cwd: root,
|
|
373
396
|
encoding: 'utf8',
|
|
397
|
+
stdio: 'pipe',
|
|
374
398
|
});
|
|
375
399
|
gitWorktrees = output
|
|
376
400
|
.split('\n\n')
|
|
@@ -390,22 +414,27 @@ function listWorktrees() {
|
|
|
390
414
|
const inGit = gitWorktrees.some(
|
|
391
415
|
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
392
416
|
);
|
|
393
|
-
const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
|
|
394
417
|
|
|
395
|
-
|
|
396
|
-
|
|
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
|
+
}
|
|
397
431
|
|
|
398
|
-
|
|
432
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
399
433
|
const merged = entry.branch && entry.baseBranch
|
|
400
434
|
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
401
435
|
: false;
|
|
402
436
|
|
|
403
|
-
return {
|
|
404
|
-
...entry,
|
|
405
|
-
status,
|
|
406
|
-
lastCommit,
|
|
407
|
-
merged,
|
|
408
|
-
};
|
|
437
|
+
return { ...entry, status, lastCommit, merged };
|
|
409
438
|
});
|
|
410
439
|
|
|
411
440
|
// Append unregistered worktrees discovered from git
|
|
@@ -427,6 +456,79 @@ function listWorktrees() {
|
|
|
427
456
|
});
|
|
428
457
|
}
|
|
429
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);
|
|
430
532
|
return entries;
|
|
431
533
|
}
|
|
432
534
|
|
|
@@ -755,6 +857,8 @@ module.exports = {
|
|
|
755
857
|
destroyWorktree,
|
|
756
858
|
mergeWorktree,
|
|
757
859
|
pruneWorktrees,
|
|
860
|
+
repairWorktrees,
|
|
861
|
+
reconcileRegistry,
|
|
758
862
|
loadRegistry,
|
|
759
863
|
getRepoRoot,
|
|
760
864
|
getLastCommitInfo,
|
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 "
|