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,19 @@
# Proposal: fix temp helper worktree cleanup

## Why

- `gx branch finish` creates `__source-probe-*` and `__integrate-*` helper worktrees under `.omx/agent-worktrees` / `.omc/agent-worktrees`, so VS Code surfaces them beside real agent lanes.
- The finish flow removes temporary integration worktrees but can still leave stale `__agent_integrate_*` refs behind, which stacks up noisy helper branches after repeated doctor/finish runs.
- Repo-level Git scan ignores currently cover only durable agent worktree roots, so moving helper worktrees into a dedicated internal temp root also needs settings parity.

## What changes

- Move temporary finish helpers into `.omx/.tmp-worktrees` and `.omc/.tmp-worktrees` instead of the user-visible agent worktree roots.
- Delete temporary integration refs directly during finish cleanup and sweep any older stale temp refs in `gx cleanup`.
- Extend managed VS Code repo-scan ignores and focused regressions for the new temp-helper placement and stale-temp-branch cleanup.

## Scope

- Affected runtime/scripts: `scripts/agent-branch-finish.sh`, `scripts/agent-worktree-prune.sh`
- Affected template mirrors: `templates/scripts/*`
- Affected config/test surface: `src/context.js`, `test/finish.test.js`, `test/worktree.test.js`, `test/setup.test.js`
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## ADDED Requirements

### Requirement: finish helper worktrees stay outside durable agent roots

`gx branch finish` SHALL create temporary `__source-probe-*` and `__integrate-*` helper worktrees under a runtime-scoped internal temp root (`.omx/.tmp-worktrees` for Codex lanes, `.omc/.tmp-worktrees` for Claude lanes) instead of the user-visible `.omx/agent-worktrees` or `.omc/agent-worktrees` roots.

#### Scenario: Codex finish helper path stays outside `.omx/agent-worktrees`

- **GIVEN** a Codex agent branch whose stored Guardex worktree root is `.omx/agent-worktrees`
- **WHEN** `gx branch finish` creates a temporary source-probe or integration helper worktree
- **THEN** the helper worktree path starts under `.omx/.tmp-worktrees`
- **AND** the helper worktree path does not start under `.omx/agent-worktrees`

#### Scenario: Claude finish helper path stays outside `.omc/agent-worktrees`

- **GIVEN** a Claude agent branch whose stored Guardex worktree root is `.omc/agent-worktrees`
- **WHEN** `gx branch finish` creates a temporary source-probe or integration helper worktree
- **THEN** the helper worktree path starts under `.omc/.tmp-worktrees`
- **AND** the helper worktree path does not start under `.omc/agent-worktrees`

### Requirement: cleanup removes stale temporary helper refs

`gx cleanup` and the finish exit path SHALL remove stale temporary helper refs (`__agent_integrate_*`, `__source-probe-*`) even when the matching helper worktree is already gone.

#### Scenario: stale temporary integration ref is swept without a worktree

- **GIVEN** a repo still has a local `__agent_integrate_*` branch ref
- **AND** no worktree is attached to that ref anymore
- **WHEN** `gx cleanup --delete-branches` runs
- **THEN** the stale temporary ref is deleted

### Requirement: repo scan ignores cover internal temp helper roots

Guardex-managed VS Code repo scan ignores SHALL include `.omx/.tmp-worktrees` and `.omc/.tmp-worktrees` alongside the durable agent worktree roots.

#### Scenario: setup appends temp helper roots to repo scan ignores

- **GIVEN** `.vscode/settings.json` already has user-defined `git.repositoryScanIgnoredFolders`
- **WHEN** `gx setup` or `gx doctor` refreshes the managed settings
- **THEN** the existing user-defined entries remain
- **AND** the resulting ignore list includes `.omx/.tmp-worktrees`, `**/.omx/.tmp-worktrees`, `.omc/.tmp-worktrees`, and `**/.omc/.tmp-worktrees`
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## 1. Spec

- [x] 1.1 Define the helper-worktree placement and stale-temp-branch cleanup requirements.
- [x] 1.2 Capture the scope in `proposal.md`.

## 2. Tests

- [x] 2.1 Update finish/worktree regressions for `.tmp-worktrees` helper paths and stale temp-ref cleanup.
- [x] 2.2 Update setup expectations for the expanded repo-scan ignore list.

## 3. Implementation

- [x] 3.1 Move temporary finish helper worktrees into runtime-scoped `.tmp-worktrees` roots.
- [x] 3.2 Delete temporary integration refs at finish exit and sweep stale helper refs in `gx cleanup`.
- [x] 3.3 Extend repo scan ignore settings for temporary helper roots.

## 4. Verification

- [x] 4.1 Run focused tests for finish/prune/setup behavior.
- [x] 4.2 Run `openspec validate agent-codex-fix-temp-helper-worktree-cleanup-2026-04-23-11-56 --type change --strict`.

## 5. Cleanup

- [ ] 5.1 Run `gx branch finish --branch agent/codex/fix-temp-helper-worktree-cleanup-2026-04-23-11-56 --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 5.2 Record PR URL + `MERGED` evidence.
- [ ] 5.3 Confirm sandbox worktree and temp refs are gone (`git worktree list`, `git branch -a`).
14 changes: 10 additions & 4 deletions scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ if [[ -z "$stored_worktree_root_rel" ]]; then
stored_worktree_root_rel=".omx/agent-worktrees"
fi
agent_worktree_root="${repo_common_root}/${stored_worktree_root_rel}"
runtime_state_root_rel="$(dirname "$stored_worktree_root_rel")"
temp_worktree_root="${repo_common_root}/${runtime_state_root_rel}/.tmp-worktrees"

if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
echo "[agent-branch-finish] --base requires a non-empty branch name." >&2
Expand Down Expand Up @@ -218,7 +220,7 @@ fi

get_worktree_for_branch() {
local branch="$1"
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${temp_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) != 1) {
Expand All @@ -242,7 +244,7 @@ remove_stale_source_probe_worktrees() {
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-" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${temp_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) == 1) {
Expand All @@ -264,11 +266,15 @@ source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
created_source_probe=0
source_probe_path=""
integration_worktree=""
integration_branch=""

cleanup() {
if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true
fi
if [[ -n "${integration_branch:-}" ]]; then
git -C "$repo_root" branch -D "$integration_branch" >/dev/null 2>&1 || true
fi
if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then
# Abort any in-progress git op so `worktree remove --force` succeeds on conflict-stuck probes.
git -C "$source_probe_path" rebase --abort >/dev/null 2>&1 || true
Expand All @@ -279,7 +285,7 @@ cleanup() {
trap cleanup EXIT

if [[ -z "$source_worktree" ]]; then
source_probe_path="${agent_worktree_root}/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
source_probe_path="${temp_worktree_root}/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$(dirname "$source_probe_path")"
git -C "$repo_root" worktree add "$source_probe_path" "$SOURCE_BRANCH" >/dev/null
source_worktree="$source_probe_path"
Expand Down Expand Up @@ -343,7 +349,7 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
fi

integration_stamp="$(date +%Y%m%d-%H%M%S)"
integration_worktree_base="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_worktree_base="${temp_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_worktree="$integration_worktree_base"
integration_branch="$integration_branch_base"
Expand Down
23 changes: 22 additions & 1 deletion scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ PR_MERGED_LOOKUP_LOADED=0
declare -A MERGED_PR_BRANCHES=()
WORKTREE_ROOT_RELS=(
".omx/agent-worktrees"
".omx/.tmp-worktrees"
".omc/agent-worktrees"
".omc/.tmp-worktrees"
)

if [[ -n "$BASE_BRANCH" ]]; then
Expand Down Expand Up @@ -90,9 +92,15 @@ repo_common_dir="$(cd "$repo_common_dir" && pwd -P)"
resolve_worktree_root_rel_for_entry() {
local entry="$1"
case "$entry" in
*/.omc/.tmp-worktrees/*)
printf '%s' '.omc/.tmp-worktrees'
;;
*/.omc/agent-worktrees/*)
printf '%s' '.omc/agent-worktrees'
;;
*/.omx/.tmp-worktrees/*)
printf '%s' '.omx/.tmp-worktrees'
;;
*)
printf '%s' '.omx/agent-worktrees'
;;
Expand Down Expand Up @@ -538,6 +546,19 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
if branch_has_worktree "$branch"; then
continue
fi
if [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
if ! branch_idle_gate "$branch" "" "temporary-worktree"; then
continue
fi
if run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then
removed_branches=$((removed_branches + 1))
echo "[agent-worktree-prune] Deleted stale temporary branch: ${branch}"
fi
continue
fi
if [[ "$branch" != agent/* ]]; then
continue
fi
if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
continue
fi
Expand Down Expand Up @@ -566,7 +587,7 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
fi
fi
fi
done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads)
fi

run_cmd git -C "$repo_root" worktree prune
Expand Down
4 changes: 4 additions & 0 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,12 @@ const AGENT_WORKTREE_RELATIVE_DIRS = [
const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
'.omx/.tmp-worktrees',
'**/.omx/.tmp-worktrees',
'.omc/agent-worktrees',
'**/.omc/agent-worktrees',
'.omc/.tmp-worktrees',
'**/.omc/.tmp-worktrees',
];
const MANAGED_GITIGNORE_PATHS = [
'.omx/',
Expand Down
14 changes: 10 additions & 4 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ if [[ -z "$stored_worktree_root_rel" ]]; then
stored_worktree_root_rel=".omx/agent-worktrees"
fi
agent_worktree_root="${repo_common_root}/${stored_worktree_root_rel}"
runtime_state_root_rel="$(dirname "$stored_worktree_root_rel")"
temp_worktree_root="${repo_common_root}/${runtime_state_root_rel}/.tmp-worktrees"

if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
echo "[agent-branch-finish] --base requires a non-empty branch name." >&2
Expand Down Expand Up @@ -218,7 +220,7 @@ fi

get_worktree_for_branch() {
local branch="$1"
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${agent_worktree_root}/__source-probe-" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${temp_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) != 1) {
Expand All @@ -242,7 +244,7 @@ remove_stale_source_probe_worktrees() {
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-" '
git -C "$repo_root" worktree list --porcelain | awk -v target="refs/heads/${branch}" -v probe_prefix="${temp_worktree_root}/__source-probe-" '
$1 == "worktree" { wt = $2 }
$1 == "branch" && $2 == target {
if (index(wt, probe_prefix) == 1) {
Expand All @@ -264,11 +266,15 @@ source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
created_source_probe=0
source_probe_path=""
integration_worktree=""
integration_branch=""

cleanup() {
if [[ -n "$integration_worktree" && -d "$integration_worktree" ]]; then
git -C "$repo_root" worktree remove "$integration_worktree" --force >/dev/null 2>&1 || true
fi
if [[ -n "${integration_branch:-}" ]]; then
git -C "$repo_root" branch -D "$integration_branch" >/dev/null 2>&1 || true
fi
if [[ "$created_source_probe" -eq 1 && -n "$source_probe_path" && -d "$source_probe_path" ]]; then
# Abort any in-progress git op so `worktree remove --force` succeeds on conflict-stuck probes.
git -C "$source_probe_path" rebase --abort >/dev/null 2>&1 || true
Expand All @@ -279,7 +285,7 @@ cleanup() {
trap cleanup EXIT

if [[ -z "$source_worktree" ]]; then
source_probe_path="${agent_worktree_root}/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
source_probe_path="${temp_worktree_root}/__source-probe-${SOURCE_BRANCH//\//__}-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$(dirname "$source_probe_path")"
git -C "$repo_root" worktree add "$source_probe_path" "$SOURCE_BRANCH" >/dev/null
source_worktree="$source_probe_path"
Expand Down Expand Up @@ -343,7 +349,7 @@ if [[ "$should_require_sync" -eq 1 ]] && git -C "$repo_root" show-ref --verify -
fi

integration_stamp="$(date +%Y%m%d-%H%M%S)"
integration_worktree_base="${agent_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_worktree_base="${temp_worktree_root}/__integrate-${BASE_BRANCH//\//__}-${integration_stamp}"
integration_branch_base="__agent_integrate_${BASE_BRANCH//\//_}_$(date +%Y%m%d_%H%M%S)"
integration_worktree="$integration_worktree_base"
integration_branch="$integration_branch_base"
Expand Down
23 changes: 22 additions & 1 deletion templates/scripts/agent-worktree-prune.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ PR_MERGED_LOOKUP_LOADED=0
declare -A MERGED_PR_BRANCHES=()
WORKTREE_ROOT_RELS=(
".omx/agent-worktrees"
".omx/.tmp-worktrees"
".omc/agent-worktrees"
".omc/.tmp-worktrees"
)

if [[ -n "$BASE_BRANCH" ]]; then
Expand Down Expand Up @@ -90,9 +92,15 @@ repo_common_dir="$(cd "$repo_common_dir" && pwd -P)"
resolve_worktree_root_rel_for_entry() {
local entry="$1"
case "$entry" in
*/.omc/.tmp-worktrees/*)
printf '%s' '.omc/.tmp-worktrees'
;;
*/.omc/agent-worktrees/*)
printf '%s' '.omc/agent-worktrees'
;;
*/.omx/.tmp-worktrees/*)
printf '%s' '.omx/.tmp-worktrees'
;;
*)
printf '%s' '.omx/agent-worktrees'
;;
Expand Down Expand Up @@ -538,6 +546,19 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
if branch_has_worktree "$branch"; then
continue
fi
if [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then
if ! branch_idle_gate "$branch" "" "temporary-worktree"; then
continue
fi
if run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1; then
removed_branches=$((removed_branches + 1))
echo "[agent-worktree-prune] Deleted stale temporary branch: ${branch}"
fi
continue
fi
if [[ "$branch" != agent/* ]]; then
continue
fi
if ! branch_idle_gate "$branch" "" "stale-merged-branch"; then
continue
fi
Expand Down Expand Up @@ -566,7 +587,7 @@ if [[ "$DELETE_BRANCHES" -eq 1 ]]; then
fi
fi
fi
done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent)
done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads)
fi

run_cmd git -C "$repo_root" worktree prune
Expand Down
4 changes: 3 additions & 1 deletion test/finish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ test('agent-branch-finish removes stale source-probe worktrees before creating a
const sourceProbePath = path.join(
repoDir,
'.omx',
'agent-worktrees',
'.tmp-worktrees',
'__source-probe-agent__test-stale-source-probe-20260422-153300',
);
result = runCmd('git', ['worktree', 'add', sourceProbePath, 'agent/test-stale-source-probe'], repoDir);
Expand All @@ -235,6 +235,8 @@ test('agent-branch-finish removes stale source-probe worktrees before creating a
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');
result = runCmd('git', ['branch', '--list', '__agent_integrate_dev_*'], repoDir);
assert.equal(result.stdout.trim(), '', 'temporary integrate branches should be removed after finish exits');
assert.match(
finish.stdout,
/Merged 'agent\/test-stale-source-probe' into 'dev' via direct flow and kept source branch\/worktree\./,
Expand Down
4 changes: 4 additions & 0 deletions test/helpers/install-test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ function assertManagedRepoVscodeSettings(settings) {
assert.deepEqual(settings['git.repositoryScanIgnoredFolders'], [
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
'.omx/.tmp-worktrees',
'**/.omx/.tmp-worktrees',
'.omc/agent-worktrees',
'**/.omc/agent-worktrees',
'.omc/.tmp-worktrees',
'**/.omc/.tmp-worktrees',
]);
}

Expand Down
4 changes: 4 additions & 0 deletions test/setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1187,8 +1187,12 @@ test('setup merges Guardex repo-scan ignores into tracked VS Code workspace sett
'custom-folder',
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
'.omx/.tmp-worktrees',
'**/.omx/.tmp-worktrees',
'.omc/agent-worktrees',
'**/.omc/agent-worktrees',
'.omc/.tmp-worktrees',
'**/.omc/.tmp-worktrees',
]);
});

Expand Down
Loading