Skip to content

Commit bc3607b

Browse files
NagyViktNagyVikt
andauthored
Prevent cleanup from deleting active agent cwd (#536)
Cleanup and finish commands can be launched through wrappers that run their destructive worktree prune subprocesses from a repo root instead of the caller's nested agent cwd. The finish/prune path now forwards the original cwd, resolves it back to the managed worktree for the same git common dir, and skips any worktree containing that cwd. Constraint: Codex and Claude hooks reload skills after tool use and fail with os error 2 when their cwd disappears. Rejected: Only pivot finish to repo root | wrapper and cleanup entrypoints still lose the caller cwd before prune. Confidence: high Scope-risk: narrow Directive: Do not remove GUARDEX_FINISH_ACTIVE_CWD or GUARDEX_PRUNE_ACTIVE_CWD propagation without reproducing finish/cleanup from a nested active worktree cwd. Tested: node --test test/worktree.test.js test/finish.test.js test/metadata.test.js Tested: bash -n scripts/agent-branch-finish.sh Tested: bash -n scripts/agent-worktree-prune.sh Tested: openspec validate --specs Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 0cc513a commit bc3607b

10 files changed

Lines changed: 168 additions & 16 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Protect Active CWD During Cleanup
2+
3+
## Problem
4+
5+
`gx branch finish --cleanup`, `gx cleanup`, and `gx worktree prune` can be invoked from a process whose real cwd is inside a managed agent worktree while the subprocess that performs cleanup runs from the repo root. In that shape, cleanup can remove the caller's worktree and leave Codex/Claude hooks or skill reloads with `No such file or directory (os error 2)`.
6+
7+
## Change
8+
9+
- Forward the caller cwd into finish and prune subprocesses.
10+
- Treat a worktree as active when the forwarded cwd is inside it, not only equal to the worktree root.
11+
- Preserve the active worktree/branch during cleanup and tell the user to leave that directory before pruning it.
12+
13+
## Verification
14+
15+
- `node --test test/worktree.test.js test/finish.test.js test/metadata.test.js`

scripts/agent-branch-finish.sh

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
165165
fi
166166

167167
repo_root="$(git rev-parse --show-toplevel)"
168+
finish_active_cwd="${GUARDEX_FINISH_ACTIVE_CWD:-$(pwd -P)}"
169+
if [[ -d "$finish_active_cwd" ]]; then
170+
finish_active_cwd="$(cd "$finish_active_cwd" && pwd -P)"
171+
else
172+
finish_active_cwd=""
173+
fi
168174
# The physical cwd may be a subdirectory inside the source worktree. Cleanup
169175
# decisions need the enclosing worktree root, otherwise finishing from `src/`
170176
# can delete the caller's cwd and turn a successful merge into a false shell
@@ -178,6 +184,36 @@ else
178184
fi
179185
repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
180186

187+
resolve_same_repo_worktree_for_cwd() {
188+
local active_cwd="$1"
189+
[[ -n "$active_cwd" && -d "$active_cwd" ]] || return 0
190+
git -C "$active_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
191+
192+
local active_worktree=""
193+
active_worktree="$(git -C "$active_cwd" rev-parse --show-toplevel 2>/dev/null || true)"
194+
[[ -n "$active_worktree" ]] || return 0
195+
196+
local active_common_raw=""
197+
local active_common_dir=""
198+
active_common_raw="$(git -C "$active_worktree" rev-parse --git-common-dir 2>/dev/null || true)"
199+
[[ -n "$active_common_raw" ]] || return 0
200+
if [[ "$active_common_raw" == /* ]]; then
201+
active_common_dir="$active_common_raw"
202+
else
203+
active_common_dir="${active_worktree}/${active_common_raw}"
204+
fi
205+
active_common_dir="$(cd "$active_common_dir" 2>/dev/null && pwd -P)" || return 0
206+
207+
if [[ "$active_common_dir" == "$common_git_dir" ]]; then
208+
cd "$active_worktree" 2>/dev/null && pwd -P
209+
fi
210+
}
211+
212+
active_cwd_worktree="$(resolve_same_repo_worktree_for_cwd "$finish_active_cwd")"
213+
if [[ -n "$active_cwd_worktree" ]]; then
214+
current_worktree="$active_cwd_worktree"
215+
fi
216+
181217
if [[ -z "$SOURCE_BRANCH" ]]; then
182218
SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
183219
fi
@@ -861,6 +897,10 @@ pivot_to_repo_root_before_prune() {
861897
fi
862898
}
863899

900+
run_guardex_prune() {
901+
GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" run_guardex_cli worktree prune "$@"
902+
}
903+
864904
if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
865905
if [[ "$source_worktree" == "$repo_root" ]]; then
866906
if is_clean_worktree "$source_worktree"; then
@@ -906,7 +946,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
906946
fi
907947

908948
pivot_to_repo_root_before_prune
909-
if ! run_guardex_cli worktree prune "${prune_args[@]}"; then
949+
if ! run_guardex_prune "${prune_args[@]}"; then
910950
echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
911951
echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2
912952
fi
@@ -920,7 +960,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
920960
fi
921961
else
922962
pivot_to_repo_root_before_prune
923-
if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then
963+
if ! run_guardex_prune --base "$BASE_BRANCH"; then
924964
echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2
925965
fi
926966

scripts/agent-worktree-prune.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
8282
fi
8383

8484
repo_root="$(git rev-parse --show-toplevel)"
85-
current_pwd="$(pwd -P)"
85+
current_pwd="${GUARDEX_PRUNE_ACTIVE_CWD:-$(pwd -P)}"
86+
if [[ -d "$current_pwd" ]]; then
87+
current_pwd="$(cd "$current_pwd" && pwd -P)"
88+
else
89+
current_pwd=""
90+
fi
8691
repo_common_dir="$(
8792
git -C "$repo_root" rev-parse --git-common-dir \
8893
| awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }'
@@ -431,7 +436,7 @@ process_entry() {
431436
return
432437
fi
433438

434-
if [[ "$wt" == "$current_pwd" ]]; then
439+
if [[ -n "$current_pwd" && ( "$wt" == "$current_pwd" || "$current_pwd" == "${wt}"/* ) ]]; then
435440
skipped_active=$((skipped_active + 1))
436441
echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}"
437442
return

src/cli/main.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3493,6 +3493,7 @@ function prompt(rawArgs) {
34933493
}
34943494

34953495
function branch(rawArgs) {
3496+
const activeCwd = process.cwd();
34963497
const [subcommand, ...rest] = rawArgs;
34973498
if (subcommand === 'start') {
34983499
const { target, passthrough } = extractTargetedArgs(rest);
@@ -3501,7 +3502,10 @@ function branch(rawArgs) {
35013502
}
35023503
if (subcommand === 'finish') {
35033504
const { target, passthrough } = extractTargetedArgs(rest);
3504-
invokePackageAsset('branchFinish', passthrough, { cwd: resolveRepoRoot(target) });
3505+
invokePackageAsset('branchFinish', passthrough, {
3506+
cwd: resolveRepoRoot(target),
3507+
env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd },
3508+
});
35053509
return;
35063510
}
35073511
if (subcommand === 'merge') return merge(rest);
@@ -3578,10 +3582,14 @@ function locks(rawArgs) {
35783582
}
35793583

35803584
function worktree(rawArgs) {
3585+
const activeCwd = process.cwd();
35813586
const [subcommand, ...rest] = rawArgs;
35823587
if (subcommand === 'prune') {
35833588
const { target, passthrough } = extractTargetedArgs(rest);
3584-
invokePackageAsset('worktreePrune', passthrough, { cwd: resolveRepoRoot(target) });
3589+
invokePackageAsset('worktreePrune', passthrough, {
3590+
cwd: resolveRepoRoot(target),
3591+
env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd },
3592+
});
35853593
return;
35863594
}
35873595
throw new Error(`Usage: ${SHORT_TOOL_NAME} worktree prune [cleanup-options]`);

src/finish/index.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
134134
}
135135

136136
function cleanup(rawArgs) {
137+
const activeCwd = process.cwd();
137138
const options = parseCleanupArgs(rawArgs);
138139
const repoRoot = resolveRepoRoot(options.target);
139140

@@ -168,7 +169,11 @@ function cleanup(rawArgs) {
168169
}
169170

170171
const runCleanupCycle = () => {
171-
const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot, stdio: 'inherit' });
172+
const runResult = runPackageAsset('worktreePrune', args, {
173+
cwd: repoRoot,
174+
stdio: 'inherit',
175+
env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd },
176+
});
172177
if (runResult.status !== 0) {
173178
throw new Error('Cleanup command failed');
174179
}
@@ -234,6 +239,7 @@ function merge(rawArgs) {
234239
}
235240

236241
function finish(rawArgs, defaults = {}) {
242+
const activeCwd = process.cwd();
237243
const options = parseFinishArgs(rawArgs, defaults);
238244
const repoRoot = resolveRepoRoot(options.target);
239245

@@ -325,7 +331,11 @@ function finish(rawArgs, defaults = {}) {
325331
continue;
326332
}
327333

328-
const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe' });
334+
const finishResult = runPackageAsset('branchFinish', finishArgs, {
335+
cwd: repoRoot,
336+
stdio: 'pipe',
337+
env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd },
338+
});
329339
if (finishResult.stdout) {
330340
process.stdout.write(finishResult.stdout);
331341
}

templates/scripts/agent-branch-finish.sh

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
165165
fi
166166

167167
repo_root="$(git rev-parse --show-toplevel)"
168+
finish_active_cwd="${GUARDEX_FINISH_ACTIVE_CWD:-$(pwd -P)}"
169+
if [[ -d "$finish_active_cwd" ]]; then
170+
finish_active_cwd="$(cd "$finish_active_cwd" && pwd -P)"
171+
else
172+
finish_active_cwd=""
173+
fi
168174
# The physical cwd may be a subdirectory inside the source worktree. Cleanup
169175
# decisions need the enclosing worktree root, otherwise finishing from `src/`
170176
# can delete the caller's cwd and turn a successful merge into a false shell
@@ -178,6 +184,36 @@ else
178184
fi
179185
repo_common_root="$(cd "$common_git_dir/.." && pwd -P)"
180186

187+
resolve_same_repo_worktree_for_cwd() {
188+
local active_cwd="$1"
189+
[[ -n "$active_cwd" && -d "$active_cwd" ]] || return 0
190+
git -C "$active_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
191+
192+
local active_worktree=""
193+
active_worktree="$(git -C "$active_cwd" rev-parse --show-toplevel 2>/dev/null || true)"
194+
[[ -n "$active_worktree" ]] || return 0
195+
196+
local active_common_raw=""
197+
local active_common_dir=""
198+
active_common_raw="$(git -C "$active_worktree" rev-parse --git-common-dir 2>/dev/null || true)"
199+
[[ -n "$active_common_raw" ]] || return 0
200+
if [[ "$active_common_raw" == /* ]]; then
201+
active_common_dir="$active_common_raw"
202+
else
203+
active_common_dir="${active_worktree}/${active_common_raw}"
204+
fi
205+
active_common_dir="$(cd "$active_common_dir" 2>/dev/null && pwd -P)" || return 0
206+
207+
if [[ "$active_common_dir" == "$common_git_dir" ]]; then
208+
cd "$active_worktree" 2>/dev/null && pwd -P
209+
fi
210+
}
211+
212+
active_cwd_worktree="$(resolve_same_repo_worktree_for_cwd "$finish_active_cwd")"
213+
if [[ -n "$active_cwd_worktree" ]]; then
214+
current_worktree="$active_cwd_worktree"
215+
fi
216+
181217
if [[ -z "$SOURCE_BRANCH" ]]; then
182218
SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
183219
fi
@@ -861,6 +897,10 @@ pivot_to_repo_root_before_prune() {
861897
fi
862898
}
863899

900+
run_guardex_prune() {
901+
GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" run_guardex_cli worktree prune "$@"
902+
}
903+
864904
if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
865905
if [[ "$source_worktree" == "$repo_root" ]]; then
866906
if is_clean_worktree "$source_worktree"; then
@@ -906,7 +946,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
906946
fi
907947

908948
pivot_to_repo_root_before_prune
909-
if ! run_guardex_cli worktree prune "${prune_args[@]}"; then
949+
if ! run_guardex_prune "${prune_args[@]}"; then
910950
echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
911951
echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2
912952
fi
@@ -920,7 +960,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
920960
fi
921961
else
922962
pivot_to_repo_root_before_prune
923-
if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then
963+
if ! run_guardex_prune --base "$BASE_BRANCH"; then
924964
echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2
925965
fi
926966

templates/scripts/agent-worktree-prune.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
8282
fi
8383

8484
repo_root="$(git rev-parse --show-toplevel)"
85-
current_pwd="$(pwd -P)"
85+
current_pwd="${GUARDEX_PRUNE_ACTIVE_CWD:-$(pwd -P)}"
86+
if [[ -d "$current_pwd" ]]; then
87+
current_pwd="$(cd "$current_pwd" && pwd -P)"
88+
else
89+
current_pwd=""
90+
fi
8691
repo_common_dir="$(
8792
git -C "$repo_root" rev-parse --git-common-dir \
8893
| awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }'
@@ -431,7 +436,7 @@ process_entry() {
431436
return
432437
fi
433438

434-
if [[ "$wt" == "$current_pwd" ]]; then
439+
if [[ -n "$current_pwd" && ( "$wt" == "$current_pwd" || "$current_pwd" == "${wt}"/* ) ]]; then
435440
skipped_active=$((skipped_active + 1))
436441
echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}"
437442
return

test/finish.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ exit 1
839839
});
840840

841841

842-
test('agent-branch-finish cleanup succeeds from active agent worktree when base branch is checked out elsewhere', () => {
842+
test('agent-branch-finish cleanup preserves forwarded active agent cwd when base branch is checked out elsewhere', () => {
843843
const repoDir = initRepo();
844844
seedCommit(repoDir);
845845
attachOriginRemote(repoDir);
@@ -897,7 +897,7 @@ exit 1
897897
`);
898898

899899
const finish = runBranchFinish(
900-
['--branch', 'agent/test-active-worktree-cleanup', '--base', 'dev', '--mode', 'pr', '--cleanup'],
900+
['--target', repoDir, '--branch', 'agent/test-active-worktree-cleanup', '--base', 'dev', '--mode', 'pr', '--cleanup'],
901901
agentSubdir,
902902
{ GUARDEX_GH_BIN: fakeGhPath },
903903
);

test/metadata.test.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,21 @@ test('agent-branch-finish pivots out of active agent cwd before every prune path
236236
const script = fs.readFileSync(path.join(repoRoot, 'scripts', 'agent-branch-finish.sh'), 'utf8');
237237

238238
assert.match(script, /current_worktree="\$repo_root"/);
239+
assert.match(script, /finish_active_cwd="\$\{GUARDEX_FINISH_ACTIVE_CWD:-\$\(pwd -P\)\}"/);
240+
assert.match(script, /resolve_same_repo_worktree_for_cwd\(\) \{/);
239241
assert.match(script, /pivot_to_repo_root_before_prune\(\) \{\n\s+if \[\[ "\$current_worktree" == "\$source_worktree"/);
240242
assert.match(script, /cd "\$repo_root" 2>\/dev\/null \|\| true/);
241243
assert.match(
242244
script,
243-
/pivot_to_repo_root_before_prune\n\s+if ! run_guardex_cli worktree prune "\$\{prune_args\[@\]\}"; then/,
245+
/GUARDEX_PRUNE_ACTIVE_CWD="\$finish_active_cwd" run_guardex_cli worktree prune "\$@"/,
244246
);
245247
assert.match(
246248
script,
247-
/else\n\s+pivot_to_repo_root_before_prune\n\s+if ! run_guardex_cli worktree prune --base "\$BASE_BRANCH"; then/,
249+
/pivot_to_repo_root_before_prune\n\s+if ! run_guardex_prune "\$\{prune_args\[@\]\}"; then/,
250+
);
251+
assert.match(
252+
script,
253+
/else\n\s+pivot_to_repo_root_before_prune\n\s+if ! run_guardex_prune --base "\$BASE_BRANCH"; then/,
248254
);
249255
});
250256

test/worktree.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,29 @@ test('worktree prune preserves dirty agent worktrees unless --force-dirty is use
110110
});
111111

112112

113+
test('worktree prune skips managed worktree containing forwarded active cwd', () => {
114+
const repoDir = initRepo();
115+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
116+
assert.equal(result.status, 0, result.stderr || result.stdout);
117+
seedCommit(repoDir);
118+
119+
const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'agent__test-active-cwd-prune');
120+
result = runCmd('git', ['worktree', 'add', '-b', 'agent/test-active-cwd-prune', worktreePath, 'dev'], repoDir);
121+
assert.equal(result.status, 0, result.stderr || result.stdout);
122+
123+
const nestedCwd = path.join(worktreePath, 'nested', 'cwd');
124+
fs.mkdirSync(nestedCwd, { recursive: true });
125+
126+
result = runWorktreePrune(['--target', repoDir, '--delete-branches'], nestedCwd);
127+
assert.equal(result.status, 0, result.stderr || result.stdout);
128+
assert.match(result.stdout, /Skipping active cwd worktree:/);
129+
assert.equal(fs.existsSync(worktreePath), true, 'active cwd worktree should remain');
130+
131+
const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-active-cwd-prune'], repoDir);
132+
assert.equal(branchResult.status, 0, 'active cwd branch should remain');
133+
});
134+
135+
113136
test('worktree prune --only-dirty-worktrees removes clean agent worktrees but keeps unmerged branch refs', () => {
114137
const repoDir = initRepo();
115138
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);

0 commit comments

Comments
 (0)