diff --git a/openspec/changes/agent-codex-protect-active-cwd-during-cleanup-2026-05-07-15-13/notes.md b/openspec/changes/agent-codex-protect-active-cwd-during-cleanup-2026-05-07-15-13/notes.md new file mode 100644 index 00000000..e9de0fda --- /dev/null +++ b/openspec/changes/agent-codex-protect-active-cwd-during-cleanup-2026-05-07-15-13/notes.md @@ -0,0 +1,15 @@ +# Protect Active CWD During Cleanup + +## Problem + +`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)`. + +## Change + +- Forward the caller cwd into finish and prune subprocesses. +- Treat a worktree as active when the forwarded cwd is inside it, not only equal to the worktree root. +- Preserve the active worktree/branch during cleanup and tell the user to leave that directory before pruning it. + +## Verification + +- `node --test test/worktree.test.js test/finish.test.js test/metadata.test.js` diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 9b2abf43..178c642e 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -165,6 +165,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" +finish_active_cwd="${GUARDEX_FINISH_ACTIVE_CWD:-$(pwd -P)}" +if [[ -d "$finish_active_cwd" ]]; then + finish_active_cwd="$(cd "$finish_active_cwd" && pwd -P)" +else + finish_active_cwd="" +fi # The physical cwd may be a subdirectory inside the source worktree. Cleanup # decisions need the enclosing worktree root, otherwise finishing from `src/` # can delete the caller's cwd and turn a successful merge into a false shell @@ -178,6 +184,36 @@ else fi repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" +resolve_same_repo_worktree_for_cwd() { + local active_cwd="$1" + [[ -n "$active_cwd" && -d "$active_cwd" ]] || return 0 + git -C "$active_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + + local active_worktree="" + active_worktree="$(git -C "$active_cwd" rev-parse --show-toplevel 2>/dev/null || true)" + [[ -n "$active_worktree" ]] || return 0 + + local active_common_raw="" + local active_common_dir="" + active_common_raw="$(git -C "$active_worktree" rev-parse --git-common-dir 2>/dev/null || true)" + [[ -n "$active_common_raw" ]] || return 0 + if [[ "$active_common_raw" == /* ]]; then + active_common_dir="$active_common_raw" + else + active_common_dir="${active_worktree}/${active_common_raw}" + fi + active_common_dir="$(cd "$active_common_dir" 2>/dev/null && pwd -P)" || return 0 + + if [[ "$active_common_dir" == "$common_git_dir" ]]; then + cd "$active_worktree" 2>/dev/null && pwd -P + fi +} + +active_cwd_worktree="$(resolve_same_repo_worktree_for_cwd "$finish_active_cwd")" +if [[ -n "$active_cwd_worktree" ]]; then + current_worktree="$active_cwd_worktree" +fi + if [[ -z "$SOURCE_BRANCH" ]]; then SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fi @@ -861,6 +897,10 @@ pivot_to_repo_root_before_prune() { fi } +run_guardex_prune() { + GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" run_guardex_cli worktree prune "$@" +} + if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then if [[ "$source_worktree" == "$repo_root" ]]; then if is_clean_worktree "$source_worktree"; then @@ -906,7 +946,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi pivot_to_repo_root_before_prune - if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + if ! run_guardex_prune "${prune_args[@]}"; then echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2 fi @@ -920,7 +960,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi else pivot_to_repo_root_before_prune - if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then + if ! run_guardex_prune --base "$BASE_BRANCH"; then echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 fi diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index f039a912..5acfa5de 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -82,7 +82,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" -current_pwd="$(pwd -P)" +current_pwd="${GUARDEX_PRUNE_ACTIVE_CWD:-$(pwd -P)}" +if [[ -d "$current_pwd" ]]; then + current_pwd="$(cd "$current_pwd" && pwd -P)" +else + current_pwd="" +fi repo_common_dir="$( git -C "$repo_root" rev-parse --git-common-dir \ | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' @@ -431,7 +436,7 @@ process_entry() { return fi - if [[ "$wt" == "$current_pwd" ]]; then + if [[ -n "$current_pwd" && ( "$wt" == "$current_pwd" || "$current_pwd" == "${wt}"/* ) ]]; then skipped_active=$((skipped_active + 1)) echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}" return diff --git a/src/cli/main.js b/src/cli/main.js index 840df4de..21d12fad 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -3493,6 +3493,7 @@ function prompt(rawArgs) { } function branch(rawArgs) { + const activeCwd = process.cwd(); const [subcommand, ...rest] = rawArgs; if (subcommand === 'start') { const { target, passthrough } = extractTargetedArgs(rest); @@ -3501,7 +3502,10 @@ function branch(rawArgs) { } if (subcommand === 'finish') { const { target, passthrough } = extractTargetedArgs(rest); - invokePackageAsset('branchFinish', passthrough, { cwd: resolveRepoRoot(target) }); + invokePackageAsset('branchFinish', passthrough, { + cwd: resolveRepoRoot(target), + env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd }, + }); return; } if (subcommand === 'merge') return merge(rest); @@ -3578,10 +3582,14 @@ function locks(rawArgs) { } function worktree(rawArgs) { + const activeCwd = process.cwd(); const [subcommand, ...rest] = rawArgs; if (subcommand === 'prune') { const { target, passthrough } = extractTargetedArgs(rest); - invokePackageAsset('worktreePrune', passthrough, { cwd: resolveRepoRoot(target) }); + invokePackageAsset('worktreePrune', passthrough, { + cwd: resolveRepoRoot(target), + env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd }, + }); return; } throw new Error(`Usage: ${SHORT_TOOL_NAME} worktree prune [cleanup-options]`); diff --git a/src/finish/index.js b/src/finish/index.js index e7414aa1..fb484256 100644 --- a/src/finish/index.js +++ b/src/finish/index.js @@ -134,6 +134,7 @@ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) { } function cleanup(rawArgs) { + const activeCwd = process.cwd(); const options = parseCleanupArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -168,7 +169,11 @@ function cleanup(rawArgs) { } const runCleanupCycle = () => { - const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot, stdio: 'inherit' }); + const runResult = runPackageAsset('worktreePrune', args, { + cwd: repoRoot, + stdio: 'inherit', + env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd }, + }); if (runResult.status !== 0) { throw new Error('Cleanup command failed'); } @@ -234,6 +239,7 @@ function merge(rawArgs) { } function finish(rawArgs, defaults = {}) { + const activeCwd = process.cwd(); const options = parseFinishArgs(rawArgs, defaults); const repoRoot = resolveRepoRoot(options.target); @@ -325,7 +331,11 @@ function finish(rawArgs, defaults = {}) { continue; } - const finishResult = runPackageAsset('branchFinish', finishArgs, { cwd: repoRoot, stdio: 'pipe' }); + const finishResult = runPackageAsset('branchFinish', finishArgs, { + cwd: repoRoot, + stdio: 'pipe', + env: { GUARDEX_FINISH_ACTIVE_CWD: activeCwd }, + }); if (finishResult.stdout) { process.stdout.write(finishResult.stdout); } diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 9b2abf43..178c642e 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -165,6 +165,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" +finish_active_cwd="${GUARDEX_FINISH_ACTIVE_CWD:-$(pwd -P)}" +if [[ -d "$finish_active_cwd" ]]; then + finish_active_cwd="$(cd "$finish_active_cwd" && pwd -P)" +else + finish_active_cwd="" +fi # The physical cwd may be a subdirectory inside the source worktree. Cleanup # decisions need the enclosing worktree root, otherwise finishing from `src/` # can delete the caller's cwd and turn a successful merge into a false shell @@ -178,6 +184,36 @@ else fi repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" +resolve_same_repo_worktree_for_cwd() { + local active_cwd="$1" + [[ -n "$active_cwd" && -d "$active_cwd" ]] || return 0 + git -C "$active_cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + + local active_worktree="" + active_worktree="$(git -C "$active_cwd" rev-parse --show-toplevel 2>/dev/null || true)" + [[ -n "$active_worktree" ]] || return 0 + + local active_common_raw="" + local active_common_dir="" + active_common_raw="$(git -C "$active_worktree" rev-parse --git-common-dir 2>/dev/null || true)" + [[ -n "$active_common_raw" ]] || return 0 + if [[ "$active_common_raw" == /* ]]; then + active_common_dir="$active_common_raw" + else + active_common_dir="${active_worktree}/${active_common_raw}" + fi + active_common_dir="$(cd "$active_common_dir" 2>/dev/null && pwd -P)" || return 0 + + if [[ "$active_common_dir" == "$common_git_dir" ]]; then + cd "$active_worktree" 2>/dev/null && pwd -P + fi +} + +active_cwd_worktree="$(resolve_same_repo_worktree_for_cwd "$finish_active_cwd")" +if [[ -n "$active_cwd_worktree" ]]; then + current_worktree="$active_cwd_worktree" +fi + if [[ -z "$SOURCE_BRANCH" ]]; then SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" fi @@ -861,6 +897,10 @@ pivot_to_repo_root_before_prune() { fi } +run_guardex_prune() { + GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" run_guardex_cli worktree prune "$@" +} + if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then if [[ "$source_worktree" == "$repo_root" ]]; then if is_clean_worktree "$source_worktree"; then @@ -906,7 +946,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi pivot_to_repo_root_before_prune - if ! run_guardex_cli worktree prune "${prune_args[@]}"; then + if ! run_guardex_prune "${prune_args[@]}"; then echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2 fi @@ -920,7 +960,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then fi else pivot_to_repo_root_before_prune - if ! run_guardex_cli worktree prune --base "$BASE_BRANCH"; then + if ! run_guardex_prune --base "$BASE_BRANCH"; then echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 fi diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index f039a912..5acfa5de 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -82,7 +82,12 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" -current_pwd="$(pwd -P)" +current_pwd="${GUARDEX_PRUNE_ACTIVE_CWD:-$(pwd -P)}" +if [[ -d "$current_pwd" ]]; then + current_pwd="$(cd "$current_pwd" && pwd -P)" +else + current_pwd="" +fi repo_common_dir="$( git -C "$repo_root" rev-parse --git-common-dir \ | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' @@ -431,7 +436,7 @@ process_entry() { return fi - if [[ "$wt" == "$current_pwd" ]]; then + if [[ -n "$current_pwd" && ( "$wt" == "$current_pwd" || "$current_pwd" == "${wt}"/* ) ]]; then skipped_active=$((skipped_active + 1)) echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}" return diff --git a/test/finish.test.js b/test/finish.test.js index bbdc3da9..bc7029b7 100644 --- a/test/finish.test.js +++ b/test/finish.test.js @@ -839,7 +839,7 @@ exit 1 }); -test('agent-branch-finish cleanup succeeds from active agent worktree when base branch is checked out elsewhere', () => { +test('agent-branch-finish cleanup preserves forwarded active agent cwd when base branch is checked out elsewhere', () => { const repoDir = initRepo(); seedCommit(repoDir); attachOriginRemote(repoDir); @@ -897,7 +897,7 @@ exit 1 `); const finish = runBranchFinish( - ['--branch', 'agent/test-active-worktree-cleanup', '--base', 'dev', '--mode', 'pr', '--cleanup'], + ['--target', repoDir, '--branch', 'agent/test-active-worktree-cleanup', '--base', 'dev', '--mode', 'pr', '--cleanup'], agentSubdir, { GUARDEX_GH_BIN: fakeGhPath }, ); diff --git a/test/metadata.test.js b/test/metadata.test.js index d5de3290..cc7a2630 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -236,15 +236,21 @@ test('agent-branch-finish pivots out of active agent cwd before every prune path const script = fs.readFileSync(path.join(repoRoot, 'scripts', 'agent-branch-finish.sh'), 'utf8'); assert.match(script, /current_worktree="\$repo_root"/); + assert.match(script, /finish_active_cwd="\$\{GUARDEX_FINISH_ACTIVE_CWD:-\$\(pwd -P\)\}"/); + assert.match(script, /resolve_same_repo_worktree_for_cwd\(\) \{/); assert.match(script, /pivot_to_repo_root_before_prune\(\) \{\n\s+if \[\[ "\$current_worktree" == "\$source_worktree"/); assert.match(script, /cd "\$repo_root" 2>\/dev\/null \|\| true/); assert.match( script, - /pivot_to_repo_root_before_prune\n\s+if ! run_guardex_cli worktree prune "\$\{prune_args\[@\]\}"; then/, + /GUARDEX_PRUNE_ACTIVE_CWD="\$finish_active_cwd" run_guardex_cli worktree prune "\$@"/, ); assert.match( script, - /else\n\s+pivot_to_repo_root_before_prune\n\s+if ! run_guardex_cli worktree prune --base "\$BASE_BRANCH"; then/, + /pivot_to_repo_root_before_prune\n\s+if ! run_guardex_prune "\$\{prune_args\[@\]\}"; then/, + ); + assert.match( + script, + /else\n\s+pivot_to_repo_root_before_prune\n\s+if ! run_guardex_prune --base "\$BASE_BRANCH"; then/, ); }); diff --git a/test/worktree.test.js b/test/worktree.test.js index badc0a8f..6c265a6c 100644 --- a/test/worktree.test.js +++ b/test/worktree.test.js @@ -110,6 +110,29 @@ test('worktree prune preserves dirty agent worktrees unless --force-dirty is use }); +test('worktree prune skips managed worktree containing forwarded active cwd', () => { + const repoDir = initRepo(); + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + seedCommit(repoDir); + + const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'agent__test-active-cwd-prune'); + result = runCmd('git', ['worktree', 'add', '-b', 'agent/test-active-cwd-prune', worktreePath, 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const nestedCwd = path.join(worktreePath, 'nested', 'cwd'); + fs.mkdirSync(nestedCwd, { recursive: true }); + + result = runWorktreePrune(['--target', repoDir, '--delete-branches'], nestedCwd); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Skipping active cwd worktree:/); + assert.equal(fs.existsSync(worktreePath), true, 'active cwd worktree should remain'); + + const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-active-cwd-prune'], repoDir); + assert.equal(branchResult.status, 0, 'active cwd branch should remain'); +}); + + test('worktree prune --only-dirty-worktrees removes clean agent worktrees but keeps unmerged branch refs', () => { const repoDir = initRepo(); let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);