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,21 @@
# agent-codex-fix-doctor-source-probe-stale-worktree-2026-04-22-15-33 (minimal / T1)

Branch: `agent/codex/fix-doctor-source-probe-stale-worktree-2026-04-22-15-33`

`gx doctor` can surface or reuse leaked `__source-probe-*` worktrees during the auto-finish sweep. That makes VS Code Source Control show a fake extra repo, can block `gx branch finish` on a dirty probe, and the compact doctor copy wrongly implies the throwaway probe is the place to keep rebasing.

Scope:
- Treat `__source-probe-*` paths as temporary worktrees even when they track an `agent/*` branch.
- Remove stale source-probe worktrees before `agent-branch-finish.sh` creates a fresh probe for the branch.
- Update doctor conflict copy so the operator rebases the real branch in a normal worktree instead of treating the probe as durable state.
- Add focused regressions for doctor output, stale-probe finish recovery, and worktree prune behavior.

Verification:
- `bash -n scripts/agent-branch-finish.sh scripts/agent-worktree-prune.sh templates/scripts/agent-branch-finish.sh templates/scripts/agent-worktree-prune.sh`
- `node --test test/doctor.test.js test/worktree.test.js test/finish.test.js`

## Cleanup

- [ ] Run `gx branch finish --branch agent/codex/fix-doctor-source-probe-stale-worktree-2026-04-22-15-33 --base main --via-pr --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state in the completion handoff.
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).
43 changes: 39 additions & 4 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -218,18 +218,48 @@ fi

get_worktree_for_branch() {
local branch="$1"
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target { print wt; exit }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) != 1) {
print wt
exit
}
}
'
}

remove_stale_source_probe_worktrees() {
local branch="$1"
local stale_probe=""

while IFS= read -r stale_probe; do
[[ -z "$stale_probe" ]] && continue
[[ "$stale_probe" == "$current_worktree" ]] && continue

echo "[agent-branch-finish] Removing stale source-probe worktree for '${branch}': ${stale_probe}" >&2
git -C "$stale_probe" rebase --abort >/dev/null 2>&1 || true
git -C "$stale_probe" merge --abort >/dev/null 2>&1 || true
git -C "$repo_root" worktree remove "$stale_probe" --force >/dev/null 2>&1 || true
done < <(
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) == 1) {
print wt
}
}
'
)
}

is_clean_worktree() {
local wt="$1"
git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
}

remove_stale_source_probe_worktrees "$SOURCE_BRANCH"
source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
created_source_probe=0
source_probe_path=""
Expand Down Expand Up @@ -295,8 +325,13 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -

echo "[agent-sync-guard] Auto-sync failed while rebasing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH}." >&2
if [[ "$rebase_active" -eq 1 ]]; then
echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
if [[ "$created_source_probe" -eq 1 ]]; then
echo "[agent-sync-guard] Temporary source-probe worktree will be cleaned up on exit." >&2
echo "[agent-sync-guard] Reattach '${SOURCE_BRANCH}' in a regular worktree, then rebase it onto origin/${BASE_BRANCH} manually." >&2
else
echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
fi
fi
exit 1
fi
Expand Down
16 changes: 15 additions & 1 deletion scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ is_managed_worktree_path() {
return 1
}

is_temporary_worktree_path() {
local entry="$1"
local name
name="$(basename "$entry")"
[[ "$name" == __agent_integrate-* || "$name" == __source-probe-* ]]
}

resolve_base_branch() {
local configured=""
local current=""
Expand Down Expand Up @@ -425,7 +432,9 @@ process_entry() {
local remove_reason=""
local branch_delete_mode="safe"

if [[ -z "$branch_ref" ]]; then
if is_temporary_worktree_path "$wt"; then
remove_reason="temporary-worktree"
elif [[ -z "$branch_ref" ]]; then
remove_reason="detached-worktree"
elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then
remove_reason="missing-branch"
Expand All @@ -452,6 +461,11 @@ process_entry() {
return
fi

if [[ "$remove_reason" == "temporary-worktree" ]]; then
git -C "$wt" rebase --abort >/dev/null 2>&1 || true
git -C "$wt" merge --abort >/dev/null 2>&1 || true
fi

if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
skipped_dirty=$((skipped_dirty + 1))
echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
Expand Down
9 changes: 8 additions & 1 deletion src/output/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,14 @@ function detectRecoverableAutoFinishConflict(message) {
if (/rebase --continue/i.test(text) && /rebase --abort/i.test(text)) {
return {
rawLabel: 'auto-finish requires manual rebase.',
summary: 'manual rebase required in the source-probe worktree; run rebase --continue or rebase --abort',
summary: 'manual rebase required on the branch before auto-finish can continue',
};
}

if (/Reattach '.+' in a regular worktree, then rebase it onto origin\/.+ manually\./i.test(text)) {
return {
rawLabel: 'auto-finish requires manual rebase.',
summary: 'manual rebase required on the branch before auto-finish can continue',
};
}

Expand Down
43 changes: 39 additions & 4 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -218,18 +218,48 @@ fi

get_worktree_for_branch() {
local branch="$1"
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target { print wt; exit }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) != 1) {
print wt
exit
}
}
'
}

remove_stale_source_probe_worktrees() {
local branch="$1"
local stale_probe=""

while IFS= read -r stale_probe; do
[[ -z "$stale_probe" ]] && continue
[[ "$stale_probe" == "$current_worktree" ]] && continue

echo "[agent-branch-finish] Removing stale source-probe worktree for '${branch}': ${stale_probe}" >&2
git -C "$stale_probe" rebase --abort >/dev/null 2>&1 || true
git -C "$stale_probe" merge --abort >/dev/null 2>&1 || true
git -C "$repo_root" worktree remove "$stale_probe" --force >/dev/null 2>&1 || true
done < <(
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) == 1) {
print wt
}
}
'
)
}

is_clean_worktree() {
local wt="$1"
git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
}

remove_stale_source_probe_worktrees "$SOURCE_BRANCH"
source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
created_source_probe=0
source_probe_path=""
Expand Down Expand Up @@ -295,8 +325,13 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -

echo "[agent-sync-guard] Auto-sync failed while rebasing '${SOURCE_BRANCH}' onto origin/${BASE_BRANCH}." >&2
if [[ "$rebase_active" -eq 1 ]]; then
echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
if [[ "$created_source_probe" -eq 1 ]]; then
echo "[agent-sync-guard] Temporary source-probe worktree will be cleaned up on exit." >&2
echo "[agent-sync-guard] Reattach '${SOURCE_BRANCH}' in a regular worktree, then rebase it onto origin/${BASE_BRANCH} manually." >&2
else
echo "[agent-sync-guard] Resolve conflicts, then run: git -C \"$source_worktree\" rebase --continue" >&2
echo "[agent-sync-guard] Or abort: git -C \"$source_worktree\" rebase --abort" >&2
fi
fi
exit 1
fi
Expand Down
16 changes: 15 additions & 1 deletion templates/scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ is_managed_worktree_path() {
return 1
}

is_temporary_worktree_path() {
local entry="$1"
local name
name="$(basename "$entry")"
[[ "$name" == __agent_integrate-* || "$name" == __source-probe-* ]]
}

resolve_base_branch() {
local configured=""
local current=""
Expand Down Expand Up @@ -425,7 +432,9 @@ process_entry() {
local remove_reason=""
local branch_delete_mode="safe"

if [[ -z "$branch_ref" ]]; then
if is_temporary_worktree_path "$wt"; then
remove_reason="temporary-worktree"
elif [[ -z "$branch_ref" ]]; then
remove_reason="detached-worktree"
elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}"; then
remove_reason="missing-branch"
Expand All @@ -452,6 +461,11 @@ process_entry() {
return
fi

if [[ "$remove_reason" == "temporary-worktree" ]]; then
git -C "$wt" rebase --abort >/dev/null 2>&1 || true
git -C "$wt" merge --abort >/dev/null 2>&1 || true
fi

if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
skipped_dirty=$((skipped_dirty + 1))
echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
Expand Down
9 changes: 6 additions & 3 deletions test/doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ exit 1
assert.match(
compactOutput,
new RegExp(
`\\[skip\\] ${escapeRegexLiteral(readyBranch)}: manual rebase required in the source-probe worktree; run rebase --continue or rebase --abort`,
`\\[skip\\] ${escapeRegexLiteral(readyBranch)}: manual rebase required on the branch before auto-finish can continue`,
),
);
assert.doesNotMatch(compactOutput, /git -C "\/tmp\/very\/long\/path\/for\/source-probe-agent-worktree/);
Expand All @@ -629,7 +629,10 @@ exit 1
assert.equal(result.status, 0, result.stderr || result.stdout);
const verboseOutput = `${result.stdout}\n${result.stderr}`;
assert.match(verboseOutput, new RegExp(`\\[skip\\] ${escapeRegexLiteral(readyBranch)}: auto-finish requires manual rebase\\.`));
assert.match(verboseOutput, /git -C ".+rebase --continue/);
assert.match(
verboseOutput,
new RegExp(`Reattach '${escapeRegexLiteral(readyBranch)}' in a regular worktree, then rebase it onto origin/main manually\\.`),
);
});


Expand Down Expand Up @@ -680,7 +683,7 @@ exit 1
assert.match(
ansiOutput,
new RegExp(
`\\u001B\\[33m\\[gitguardex\\]\\s+\\[skip\\] ${escapeRegexLiteral(readyBranch)}: manual rebase required in the source-probe worktree; run rebase --continue or rebase --abort\\u001B\\[0m`,
`\\u001B\\[33m\\[gitguardex\\]\\s+\\[skip\\] ${escapeRegexLiteral(readyBranch)}: manual rebase required on the branch before auto-finish can continue\\u001B\\[0m`,
),
);
assert.match(ansiOutput, /\u001B\[32m\[gitguardex\] ✅ Repo is fully safe\.\u001B\[0m/);
Expand Down
44 changes: 44 additions & 0 deletions test/finish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,50 @@ test('agent-branch-finish auto-syncs source branch when behind origin/dev', () =
});


test('agent-branch-finish removes stale source-probe worktrees before creating a fresh probe', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);

let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
result = runCmd('git', ['add', '.'], repoDir);
assert.equal(result.status, 0, result.stderr);
result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, {
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
result = runCmd('git', ['push', 'origin', 'dev'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);

result = runCmd('git', ['checkout', '-b', 'agent/test-stale-source-probe'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
commitFile(repoDir, 'agent-stale-source-probe.txt', 'agent branch change\n', 'agent branch change');

result = runCmd('git', ['checkout', 'dev'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);

const sourceProbePath = path.join(
repoDir,
'.omx',
'agent-worktrees',
'__source-probe-agent__test-stale-source-probe-20260422-153300',
);
result = runCmd('git', ['worktree', 'add', sourceProbePath, 'agent/test-stale-source-probe'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
fs.writeFileSync(path.join(sourceProbePath, 'agent-stale-source-probe.txt'), 'stale probe dirty change\n', 'utf8');

const finish = runBranchFinish(['--branch', 'agent/test-stale-source-probe'], repoDir);
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
assert.match(finish.stderr, /Removing stale source-probe worktree for 'agent\/test-stale-source-probe'/);
assert.equal(fs.existsSync(sourceProbePath), false, 'stale source-probe worktree should be removed before finish continues');
assert.match(
finish.stdout,
/Merged 'agent\/test-stale-source-probe' into 'dev' via direct flow and kept source branch\/worktree\./,
);
});


test('agent-branch-finish pr mode continues cleanup when gh merge only fails local branch deletion', () => {
const repoDir = initRepo();
seedCommit(repoDir);
Expand Down
32 changes: 32 additions & 0 deletions test/worktree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,38 @@ test('worktree prune --only-dirty-worktrees removes clean agent worktrees but ke
});


test('worktree prune removes __source-probe worktrees even when they track agent branches', () => {
const repoDir = initRepo();
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
seedCommit(repoDir);

result = runCmd('git', ['checkout', '-b', 'agent/test-source-probe-prune'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
commitFile(repoDir, 'source-probe-prune.txt', 'agent branch change\n', 'agent branch change');

result = runCmd('git', ['checkout', 'dev'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);

const sourceProbePath = path.join(
repoDir,
'.omx',
'agent-worktrees',
'__source-probe-agent__test-source-probe-prune-20260422-153300',
);
result = runCmd('git', ['worktree', 'add', sourceProbePath, 'agent/test-source-probe-prune'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(fs.existsSync(sourceProbePath), true);

result = runWorktreePrune([], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(fs.existsSync(sourceProbePath), false, 'temporary source-probe worktree should be removed');

const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-source-probe-prune'], repoDir);
assert.equal(branchResult.status, 0, 'agent branch ref should remain after pruning only the temporary worktree');
});


test('worktree prune reroutes foreign worktrees to the owning repo .omx root', () => {
const repoDir = initRepo();
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
Expand Down