Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`
44 changes: 42 additions & 2 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 7 additions & 2 deletions scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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]`);
Expand Down
14 changes: 12 additions & 2 deletions src/finish/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down
44 changes: 42 additions & 2 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
9 changes: 7 additions & 2 deletions templates/scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }'
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions test/finish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
);
Expand Down
10 changes: 8 additions & 2 deletions test/metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
);
});

Expand Down
23 changes: 23 additions & 0 deletions test/worktree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading