Skip to content

Commit ab32d5b

Browse files
NagyViktNagyVikt
andauthored
Make finish wait and clean up by default (#545)
Claude finish flows could strand work at PR-created because the lower-level branch finish script defaulted wait and cleanup off while higher-level gx finish defaulted them on. Align the script and template defaults, keep explicit --no-wait-for-merge and --no-cleanup escape hatches, and make post-merge base refresh visible without pulling dirty local base worktrees. Preserve forwarded active-cwd pruning context when branch finish calls gx worktree prune through the CLI wrapper. Constraint: Claude AGENTS flow requires gx branch finish --via-pr to wait for merge and cleanup by default. Constraint: Local base refresh must not overwrite dirty user worktrees. Rejected: Require every Claude prompt to include --wait-for-merge --cleanup | the script defaults still permit stranded PR-created lanes. Confidence: high. Scope-risk: moderate. Directive: Do not make PR finish pending-by-default again; use --no-wait-for-merge or --no-cleanup for explicit opt-out tests and workflows. Tested: bash -n scripts/agent-branch-finish.sh templates/scripts/agent-branch-finish.sh; node --test test/finish.test.js; git diff --check; openspec validate --specs. Not-tested: Live GitHub branch protection timing against a real protected remote. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent e8e7651 commit ab32d5b

4 files changed

Lines changed: 192 additions & 20 deletions

File tree

scripts/agent-branch-finish.sh

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ MERGE_MODE="auto"
1111
GH_BIN="${GUARDEX_GH_BIN:-gh}"
1212
NODE_BIN="${GUARDEX_NODE_BIN:-node}"
1313
CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}"
14-
CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}"
15-
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}"
14+
CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-true}"
15+
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-true}"
1616
WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}"
1717
WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}"
1818
PARENT_GITLINK_AUTO_COMMIT_RAW="${GUARDEX_FINISH_PARENT_GITLINK_AUTO_COMMIT:-true}"
@@ -64,8 +64,8 @@ normalize_int() {
6464
printf '%s' "$value"
6565
}
6666

67-
CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
68-
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")"
67+
CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "1")"
68+
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "1")"
6969
WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
7070
WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
7171
PARENT_GITLINK_AUTO_COMMIT="$(normalize_bool "$PARENT_GITLINK_AUTO_COMMIT_RAW" "1")"
@@ -372,6 +372,22 @@ is_clean_worktree() {
372372
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
373373
}
374374

375+
refresh_clean_base_worktree() {
376+
local wt="$1"
377+
[[ -z "$wt" || "$PUSH_ENABLED" -ne 1 ]] && return 0
378+
379+
if ! is_clean_worktree "$wt"; then
380+
echo "[agent-branch-finish] Warning: local ${BASE_BRANCH} worktree is dirty; skipping 'git pull --ff-only origin ${BASE_BRANCH}' for ${wt}." >&2
381+
return 0
382+
fi
383+
384+
if GUARDEX_DISABLE_POST_MERGE_CLEANUP=1 GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" git -C "$wt" pull --ff-only origin "$BASE_BRANCH" >/dev/null; then
385+
echo "[agent-branch-finish] Refreshed local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}"
386+
else
387+
echo "[agent-branch-finish] Warning: failed to refresh local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}" >&2
388+
fi
389+
}
390+
375391
remove_stale_source_probe_worktrees "$SOURCE_BRANCH"
376392
source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
377393
created_source_probe=0
@@ -957,9 +973,7 @@ fi
957973
run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
958974

959975
base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
960-
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
961-
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
962-
fi
976+
refresh_clean_base_worktree "$base_worktree"
963977
maybe_auto_commit_parent_gitlink "$base_worktree"
964978

965979
# Pivot out of the agent worktree before prune calls that may remove it.
@@ -985,7 +999,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
985999
git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
9861000
fi
9871001
if [[ "$switched_to_base" -eq 1 && "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
988-
git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
1002+
refresh_clean_base_worktree "$source_worktree"
9891003
fi
9901004
fi
9911005
elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then

src/cli/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3606,7 +3606,7 @@ function worktree(rawArgs) {
36063606
const { target, passthrough } = extractTargetedArgs(rest);
36073607
invokePackageAsset('worktreePrune', passthrough, {
36083608
cwd: resolveRepoRoot(target),
3609-
env: { GUARDEX_PRUNE_ACTIVE_CWD: activeCwd },
3609+
env: { GUARDEX_PRUNE_ACTIVE_CWD: process.env.GUARDEX_PRUNE_ACTIVE_CWD || activeCwd },
36103610
});
36113611
return;
36123612
}

templates/scripts/agent-branch-finish.sh

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ MERGE_MODE="auto"
1111
GH_BIN="${GUARDEX_GH_BIN:-gh}"
1212
NODE_BIN="${GUARDEX_NODE_BIN:-node}"
1313
CLI_ENTRY="${GUARDEX_CLI_ENTRY:-}"
14-
CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-false}"
15-
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-false}"
14+
CLEANUP_AFTER_MERGE_RAW="${GUARDEX_FINISH_CLEANUP:-true}"
15+
WAIT_FOR_MERGE_RAW="${GUARDEX_FINISH_WAIT_FOR_MERGE:-true}"
1616
WAIT_TIMEOUT_SECONDS_RAW="${GUARDEX_FINISH_WAIT_TIMEOUT_SECONDS:-1800}"
1717
WAIT_POLL_SECONDS_RAW="${GUARDEX_FINISH_WAIT_POLL_SECONDS:-10}"
1818
PARENT_GITLINK_AUTO_COMMIT_RAW="${GUARDEX_FINISH_PARENT_GITLINK_AUTO_COMMIT:-true}"
@@ -64,8 +64,8 @@ normalize_int() {
6464
printf '%s' "$value"
6565
}
6666

67-
CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")"
68-
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "0")"
67+
CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "1")"
68+
WAIT_FOR_MERGE="$(normalize_bool "$WAIT_FOR_MERGE_RAW" "1")"
6969
WAIT_TIMEOUT_SECONDS="$(normalize_int "$WAIT_TIMEOUT_SECONDS_RAW" "1800" "30")"
7070
WAIT_POLL_SECONDS="$(normalize_int "$WAIT_POLL_SECONDS_RAW" "10" "0")"
7171
PARENT_GITLINK_AUTO_COMMIT="$(normalize_bool "$PARENT_GITLINK_AUTO_COMMIT_RAW" "1")"
@@ -372,6 +372,22 @@ is_clean_worktree() {
372372
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json"
373373
}
374374

375+
refresh_clean_base_worktree() {
376+
local wt="$1"
377+
[[ -z "$wt" || "$PUSH_ENABLED" -ne 1 ]] && return 0
378+
379+
if ! is_clean_worktree "$wt"; then
380+
echo "[agent-branch-finish] Warning: local ${BASE_BRANCH} worktree is dirty; skipping 'git pull --ff-only origin ${BASE_BRANCH}' for ${wt}." >&2
381+
return 0
382+
fi
383+
384+
if GUARDEX_DISABLE_POST_MERGE_CLEANUP=1 GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" git -C "$wt" pull --ff-only origin "$BASE_BRANCH" >/dev/null; then
385+
echo "[agent-branch-finish] Refreshed local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}"
386+
else
387+
echo "[agent-branch-finish] Warning: failed to refresh local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}" >&2
388+
fi
389+
}
390+
375391
remove_stale_source_probe_worktrees "$SOURCE_BRANCH"
376392
source_worktree="$(get_worktree_for_branch "$SOURCE_BRANCH")"
377393
created_source_probe=0
@@ -957,9 +973,7 @@ fi
957973
run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
958974

959975
base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")"
960-
if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then
961-
git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
962-
fi
976+
refresh_clean_base_worktree "$base_worktree"
963977
maybe_auto_commit_parent_gitlink "$base_worktree"
964978

965979
# Pivot out of the agent worktree before prune calls that may remove it.
@@ -985,7 +999,7 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
985999
git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true
9861000
fi
9871001
if [[ "$switched_to_base" -eq 1 && "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then
988-
git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true
1002+
refresh_clean_base_worktree "$source_worktree"
9891003
fi
9901004
fi
9911005
elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* ]]; then

test/finish.test.js

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ test('agent-branch-finish handles Claude-root worktrees when inferring base from
9393
result = runCmd('git', ['worktree', 'add', auxWorktree, 'main'], repoDir);
9494
assert.equal(result.status, 0, result.stderr || result.stdout);
9595

96-
const finish = runBranchFinish(['--branch', agentBranch], repoDir);
96+
const finish = runBranchFinish(['--branch', agentBranch, '--no-cleanup'], repoDir);
9797
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
9898
assert.match(finish.stdout, new RegExp(`Merged '${escapeRegexLiteral(agentBranch)}' into 'main'`));
9999

@@ -325,7 +325,7 @@ test('agent-branch-finish auto-syncs source branch when behind origin/dev', () =
325325
result = runCmd('git', ['checkout', 'agent/test-finish-sync-guard'], repoDir);
326326
assert.equal(result.status, 0, result.stderr);
327327

328-
const finish = runBranchFinish(['--branch', 'agent/test-finish-sync-guard'], repoDir);
328+
const finish = runBranchFinish(['--branch', 'agent/test-finish-sync-guard', '--no-cleanup'], repoDir);
329329
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
330330
assert.match(finish.stderr, /agent-sync-guard/);
331331
assert.match(finish.stderr, /Auto-syncing 'agent\/test-finish-sync-guard' onto origin\/dev before finish/);
@@ -373,7 +373,7 @@ test('agent-branch-finish removes stale source-probe worktrees before creating a
373373
assert.equal(result.status, 0, result.stderr || result.stdout);
374374
fs.writeFileSync(path.join(sourceProbePath, 'agent-stale-source-probe.txt'), 'stale probe dirty change\n', 'utf8');
375375

376-
const finish = runBranchFinish(['--branch', 'agent/test-stale-source-probe'], repoDir);
376+
const finish = runBranchFinish(['--branch', 'agent/test-stale-source-probe', '--no-cleanup'], repoDir);
377377
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
378378
assert.match(finish.stderr, /Removing stale source-probe worktree for 'agent\/test-stale-source-probe'/);
379379
assert.equal(fs.existsSync(sourceProbePath), false, 'stale source-probe worktree should be removed before finish continues');
@@ -1078,6 +1078,150 @@ exit 1
10781078
});
10791079

10801080

1081+
test('agent-branch-finish defaults bare PR finish to wait, cleanup, and base refresh', () => {
1082+
const repoDir = initRepo();
1083+
seedCommit(repoDir);
1084+
attachOriginRemote(repoDir);
1085+
1086+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
1087+
assert.equal(result.status, 0, result.stderr || result.stdout);
1088+
result = runCmd('git', ['add', '.'], repoDir);
1089+
assert.equal(result.status, 0, result.stderr);
1090+
result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, {
1091+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
1092+
});
1093+
assert.equal(result.status, 0, result.stderr);
1094+
result = runCmd('git', ['push', 'origin', 'dev'], repoDir);
1095+
assert.equal(result.status, 0, result.stderr);
1096+
1097+
result = runCmd('git', ['checkout', '-b', 'agent/test-pr-default-wait-cleanup'], repoDir);
1098+
assert.equal(result.status, 0, result.stderr);
1099+
commitFile(repoDir, 'agent-pr-default.txt', 'agent default wait cleanup\n', 'agent default wait cleanup change');
1100+
1101+
const auxWorktree = path.join(path.dirname(repoDir), 'aux-default-pr-dev');
1102+
result = runCmd('git', ['worktree', 'add', auxWorktree, 'dev'], repoDir);
1103+
assert.equal(result.status, 0, result.stderr || result.stdout);
1104+
1105+
const ghMergeState = path.join(repoDir, '.finish-gh-default-merge-attempts');
1106+
const { fakePath: fakeGhPath } = createFakeGhScript(`
1107+
if [[ "$1" == "pr" && "$2" == "create" ]]; then
1108+
exit 0
1109+
fi
1110+
if [[ "$1" == "pr" && "$2" == "view" ]]; then
1111+
if [[ " $* " == *" --json url "* ]]; then
1112+
echo "https://example.test/pr/default"
1113+
exit 0
1114+
fi
1115+
if [[ " $* " == *" --json state,mergedAt,url "* ]]; then
1116+
attempts=0
1117+
if [[ -f "${'${GUARDEX_TEST_GH_MERGE_STATE}'}" ]]; then
1118+
attempts="$(cat "${'${GUARDEX_TEST_GH_MERGE_STATE}'}")"
1119+
fi
1120+
if [[ "$attempts" -ge 2 ]]; then
1121+
echo -e "MERGED\\x1f2026-05-11T00:00:00Z\\x1fhttps://example.test/pr/default"
1122+
else
1123+
echo -e "OPEN\\x1f\\x1fhttps://example.test/pr/default"
1124+
fi
1125+
exit 0
1126+
fi
1127+
echo "unexpected gh pr view args: $*" >&2
1128+
exit 1
1129+
fi
1130+
if [[ "$1" == "pr" && "$2" == "merge" ]]; then
1131+
attempts=0
1132+
if [[ -f "${'${GUARDEX_TEST_GH_MERGE_STATE}'}" ]]; then
1133+
attempts="$(cat "${'${GUARDEX_TEST_GH_MERGE_STATE}'}")"
1134+
fi
1135+
attempts=$((attempts + 1))
1136+
echo "$attempts" > "${'${GUARDEX_TEST_GH_MERGE_STATE}'}"
1137+
if [[ "$attempts" -lt 2 ]]; then
1138+
echo "Required status check \\"test (node 22)\\" is expected." >&2
1139+
exit 1
1140+
fi
1141+
git push origin "$3:dev" >/dev/null 2>&1
1142+
exit 0
1143+
fi
1144+
echo "unexpected gh args: $*" >&2
1145+
exit 1
1146+
`);
1147+
1148+
const finish = runBranchFinish(
1149+
[
1150+
'--branch',
1151+
'agent/test-pr-default-wait-cleanup',
1152+
'--mode',
1153+
'pr',
1154+
'--wait-timeout-seconds',
1155+
'60',
1156+
'--wait-poll-seconds',
1157+
'0',
1158+
],
1159+
repoDir,
1160+
{
1161+
GUARDEX_GH_BIN: fakeGhPath,
1162+
GUARDEX_TEST_GH_MERGE_STATE: ghMergeState,
1163+
},
1164+
);
1165+
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
1166+
assert.equal(fs.readFileSync(ghMergeState, 'utf8').trim(), '2', 'bare PR finish should wait and retry merge by default');
1167+
assert.match(finish.stdout, /Merged 'agent\/test-pr-default-wait-cleanup' into 'dev' via pr flow and cleaned source branch\/worktree\./);
1168+
assert.match(finish.stdout, /Refreshed local dev worktree with 'git pull --ff-only origin dev': /);
1169+
assert.equal(fs.existsSync(auxWorktree), true, 'default cleanup should keep the checked-out dev worktree');
1170+
assert.equal(
1171+
fs.existsSync(path.join(repoDir, 'agent-pr-default.txt')),
1172+
true,
1173+
'clean local dev checkout should be refreshed after PR merge',
1174+
);
1175+
1176+
result = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-pr-default-wait-cleanup'], repoDir);
1177+
assert.notEqual(result.status, 0, 'default cleanup should delete the local agent branch');
1178+
result = runCmd('git', ['ls-remote', '--heads', 'origin', 'agent/test-pr-default-wait-cleanup'], repoDir);
1179+
assert.equal(result.stdout.trim(), '', 'default cleanup should delete the remote agent branch');
1180+
});
1181+
1182+
1183+
test('agent-branch-finish warns instead of pulling dirty local base worktree', () => {
1184+
const repoDir = initRepo();
1185+
seedCommit(repoDir);
1186+
attachOriginRemote(repoDir);
1187+
1188+
let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
1189+
assert.equal(result.status, 0, result.stderr || result.stdout);
1190+
result = runCmd('git', ['add', '.'], repoDir);
1191+
assert.equal(result.status, 0, result.stderr);
1192+
result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, {
1193+
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
1194+
});
1195+
assert.equal(result.status, 0, result.stderr);
1196+
result = runCmd('git', ['push', 'origin', 'dev'], repoDir);
1197+
assert.equal(result.status, 0, result.stderr);
1198+
1199+
result = runCmd('git', ['checkout', '-b', 'agent/test-dirty-base-refresh-warning'], repoDir);
1200+
assert.equal(result.status, 0, result.stderr);
1201+
commitFile(repoDir, 'agent-dirty-base-refresh.txt', 'agent dirty base refresh\n', 'agent dirty base refresh change');
1202+
1203+
const auxWorktree = path.join(path.dirname(repoDir), 'aux-dirty-dev');
1204+
result = runCmd('git', ['worktree', 'add', auxWorktree, 'dev'], repoDir);
1205+
assert.equal(result.status, 0, result.stderr || result.stdout);
1206+
fs.writeFileSync(path.join(auxWorktree, 'package.json'), '{"dirty":true}\n', 'utf8');
1207+
1208+
const finish = runBranchFinish(
1209+
['--branch', 'agent/test-dirty-base-refresh-warning', '--base', 'dev', '--direct-only', '--no-cleanup'],
1210+
repoDir,
1211+
);
1212+
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
1213+
assert.match(
1214+
finish.stderr,
1215+
/Warning: local dev worktree is dirty; skipping 'git pull --ff-only origin dev' for /,
1216+
);
1217+
assert.equal(
1218+
fs.existsSync(path.join(auxWorktree, 'agent-dirty-base-refresh.txt')),
1219+
false,
1220+
'dirty local dev worktree should not be pulled implicitly',
1221+
);
1222+
});
1223+
1224+
10811225
test('cleanup command removes merged agent branch/worktree and remote ref', () => {
10821226
const repoDir = initRepo();
10831227
seedCommit(repoDir);

0 commit comments

Comments
 (0)