Skip to content

Commit fc70c5f

Browse files
NagyViktNagyVikt
andauthored
Keep local main pull-only by automatically cleaning finished agent worktrees (#33)
Stale agent worktrees were piling up in VS Code Source Control, and manual cleanup required passing hardcoded base arguments. This change makes cleanup default and safer: `agent:cleanup` now uses base auto-detection, prune can preserve dirty worktrees unless explicitly forced, and codex-agent triggers post-session prune automatically. Tests now cover clean-sandbox auto-prune and dirty-sandbox preservation paths so the workflow stays predictable while reducing branch/worktree clutter. Constraint: Cleanup must not delete in-progress work by default Rejected: Always force-delete dirty worktrees after codex exit | risks losing active edits Confidence: high Scope-risk: moderate Reversibility: clean Directive: If prune rules change, keep codex-agent post-session behavior and dirty-preservation tests aligned Tested: npm test (54/54); node --check bin/multiagent-safety.js Not-tested: End-to-end codex-agent run against real Codex CLI interactive session Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 3e3d9b1 commit fc70c5f

6 files changed

Lines changed: 270 additions & 19 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,8 @@ gx protect reset [--target <path>]
270270
gx sync --check [--target <path>] [--base <branch>] [--json]
271271
gx sync [--target <path>] [--base <branch>] [--strategy rebase|merge] [--ff-only]
272272
gx report scorecard [--target <path>] [--repo github.com/<owner>/<repo>] [--scorecard-json <file>] [--output-dir <path>] [--date YYYY-MM-DD]
273-
bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanup
273+
bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup (auto base detection)
274+
bash scripts/agent-worktree-prune.sh --force-dirty # remove stale dirty worktrees too
274275
bash scripts/openspec/init-plan-workspace.sh <plan-slug> # optional OpenSpec plan scaffold
275276
```
276277

@@ -284,6 +285,7 @@ and asks `[y/N]` whether to update immediately (default is `N`).
284285
- Interactive prompt is strict (`[y/n]`) and waits for explicit answer.
285286
- Non-interactive setup: skips global installs by default; use `--yes-global-install` to force.
286287
- In already-initialized repos, `setup` / `install` / `fix` / `doctor` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance.
288+
- `scripts/codex-agent.sh` now auto-runs worktree prune after a Codex session; clean sandbox branches are removed automatically, dirty ones are kept.
287289

288290
## Advanced commands
289291

bin/multiagent-safety.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ function ensurePackageScripts(repoRoot, dryRun) {
489489
'agent:codex': 'bash ./scripts/codex-agent.sh',
490490
'agent:branch:start': 'bash ./scripts/agent-branch-start.sh',
491491
'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh',
492-
'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev',
492+
'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh',
493493
'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh',
494494
'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim',
495495
'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete',

scripts/agent-worktree-prune.sh

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
BASE_BRANCH="dev"
4+
BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
5+
BASE_BRANCH_EXPLICIT=0
56
DRY_RUN=0
7+
FORCE_DIRTY=0
8+
9+
if [[ -n "$BASE_BRANCH" ]]; then
10+
BASE_BRANCH_EXPLICIT=1
11+
fi
612

713
while [[ $# -gt 0 ]]; do
814
case "$1" in
915
--base)
10-
BASE_BRANCH="${2:-dev}"
16+
BASE_BRANCH="${2:-}"
17+
BASE_BRANCH_EXPLICIT=1
1118
shift 2
1219
;;
1320
--dry-run)
1421
DRY_RUN=1
1522
shift
1623
;;
24+
--force-dirty)
25+
FORCE_DIRTY=1
26+
shift
27+
;;
1728
*)
1829
echo "[agent-worktree-prune] Unknown argument: $1" >&2
19-
echo "Usage: $0 [--base <branch>] [--dry-run]" >&2
30+
echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty]" >&2
2031
exit 1
2132
;;
2233
esac
@@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)"
3142
current_pwd="$(pwd -P)"
3243
worktree_root="${repo_root}/.omx/agent-worktrees"
3344

45+
resolve_base_branch() {
46+
local configured=""
47+
local current=""
48+
49+
configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
50+
if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then
51+
printf '%s' "$configured"
52+
return 0
53+
fi
54+
55+
current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
56+
if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then
57+
printf '%s' "$current"
58+
return 0
59+
fi
60+
61+
for fallback in main dev; do
62+
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then
63+
printf '%s' "$fallback"
64+
return 0
65+
fi
66+
done
67+
68+
printf '%s' ""
69+
}
70+
71+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
72+
echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2
73+
exit 1
74+
fi
75+
76+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
77+
BASE_BRANCH="$(resolve_base_branch)"
78+
fi
79+
80+
if [[ -z "$BASE_BRANCH" ]]; then
81+
echo "[agent-worktree-prune] Unable to infer base branch. Pass --base <branch>." >&2
82+
exit 1
83+
fi
84+
3485
if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
3586
echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2
3687
exit 1
@@ -49,9 +100,17 @@ branch_has_worktree() {
49100
git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$"
50101
}
51102

103+
is_clean_worktree() {
104+
local wt="$1"
105+
git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
106+
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
107+
&& [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
108+
}
109+
52110
removed_worktrees=0
53111
removed_branches=0
54112
skipped_active=0
113+
skipped_dirty=0
55114

56115
process_entry() {
57116
local wt="$1"
@@ -89,6 +148,12 @@ process_entry() {
89148
return
90149
fi
91150

151+
if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
152+
skipped_dirty=$((skipped_dirty + 1))
153+
echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
154+
return
155+
fi
156+
92157
echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}"
93158
run_cmd git -C "$repo_root" worktree remove "$wt" --force
94159
removed_worktrees=$((removed_worktrees + 1))
@@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads
149214

150215
run_cmd git -C "$repo_root" worktree prune
151216

152-
echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}"
217+
echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}"
153218
if [[ "$skipped_active" -gt 0 ]]; then
154219
echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2
155220
fi
221+
if [[ "$skipped_dirty" -gt 0 ]]; then
222+
echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2
223+
fi

templates/scripts/agent-worktree-prune.sh

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
BASE_BRANCH="dev"
4+
BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}"
5+
BASE_BRANCH_EXPLICIT=0
56
DRY_RUN=0
7+
FORCE_DIRTY=0
8+
9+
if [[ -n "$BASE_BRANCH" ]]; then
10+
BASE_BRANCH_EXPLICIT=1
11+
fi
612

713
while [[ $# -gt 0 ]]; do
814
case "$1" in
915
--base)
10-
BASE_BRANCH="${2:-dev}"
16+
BASE_BRANCH="${2:-}"
17+
BASE_BRANCH_EXPLICIT=1
1118
shift 2
1219
;;
1320
--dry-run)
1421
DRY_RUN=1
1522
shift
1623
;;
24+
--force-dirty)
25+
FORCE_DIRTY=1
26+
shift
27+
;;
1728
*)
1829
echo "[agent-worktree-prune] Unknown argument: $1" >&2
19-
echo "Usage: $0 [--base <branch>] [--dry-run]" >&2
30+
echo "Usage: $0 [--base <branch>] [--dry-run] [--force-dirty]" >&2
2031
exit 1
2132
;;
2233
esac
@@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)"
3142
current_pwd="$(pwd -P)"
3243
worktree_root="${repo_root}/.omx/agent-worktrees"
3344

45+
resolve_base_branch() {
46+
local configured=""
47+
local current=""
48+
49+
configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)"
50+
if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then
51+
printf '%s' "$configured"
52+
return 0
53+
fi
54+
55+
current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
56+
if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then
57+
printf '%s' "$current"
58+
return 0
59+
fi
60+
61+
for fallback in main dev; do
62+
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then
63+
printf '%s' "$fallback"
64+
return 0
65+
fi
66+
done
67+
68+
printf '%s' ""
69+
}
70+
71+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
72+
echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2
73+
exit 1
74+
fi
75+
76+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then
77+
BASE_BRANCH="$(resolve_base_branch)"
78+
fi
79+
80+
if [[ -z "$BASE_BRANCH" ]]; then
81+
echo "[agent-worktree-prune] Unable to infer base branch. Pass --base <branch>." >&2
82+
exit 1
83+
fi
84+
3485
if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then
3586
echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2
3687
exit 1
@@ -49,9 +100,17 @@ branch_has_worktree() {
49100
git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$"
50101
}
51102

103+
is_clean_worktree() {
104+
local wt="$1"
105+
git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
106+
&& git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \
107+
&& [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]]
108+
}
109+
52110
removed_worktrees=0
53111
removed_branches=0
54112
skipped_active=0
113+
skipped_dirty=0
55114

56115
process_entry() {
57116
local wt="$1"
@@ -89,6 +148,12 @@ process_entry() {
89148
return
90149
fi
91150

151+
if [[ "$FORCE_DIRTY" -ne 1 ]] && ! is_clean_worktree "$wt"; then
152+
skipped_dirty=$((skipped_dirty + 1))
153+
echo "[agent-worktree-prune] Skipping dirty worktree (${remove_reason}): ${wt}"
154+
return
155+
fi
156+
92157
echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}"
93158
run_cmd git -C "$repo_root" worktree remove "$wt" --force
94159
removed_worktrees=$((removed_worktrees + 1))
@@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads
149214

150215
run_cmd git -C "$repo_root" worktree prune
151216

152-
echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}"
217+
echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}"
153218
if [[ "$skipped_active" -gt 0 ]]; then
154219
echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2
155220
fi
221+
if [[ "$skipped_dirty" -gt 0 ]]; then
222+
echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2
223+
fi

templates/scripts/codex-agent.sh

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then
6565
exit 127
6666
fi
6767

68-
if [[ ! -x "scripts/agent-branch-start.sh" ]]; then
68+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
69+
echo "[codex-agent] Not inside a git repository." >&2
70+
exit 1
71+
fi
72+
repo_root="$(git rev-parse --show-toplevel)"
73+
74+
if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then
6975
echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2
7076
exit 1
7177
fi
@@ -75,7 +81,7 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then
7581
start_args+=("$BASE_BRANCH")
7682
fi
7783

78-
start_output="$(bash scripts/agent-branch-start.sh "${start_args[@]}")"
84+
start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}")"
7985
printf '%s\n' "$start_output"
8086

8187
worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)"
@@ -91,4 +97,32 @@ fi
9197

9298
echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path"
9399
cd "$worktree_path"
94-
exec "$CODEX_BIN" "$@"
100+
set +e
101+
"$CODEX_BIN" "$@"
102+
codex_exit="$?"
103+
set -e
104+
105+
cd "$repo_root"
106+
107+
if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then
108+
echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..."
109+
prune_args=()
110+
if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then
111+
prune_args+=(--base "$BASE_BRANCH")
112+
fi
113+
if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then
114+
echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2
115+
fi
116+
fi
117+
118+
if [[ ! -d "$worktree_path" ]]; then
119+
echo "[codex-agent] Auto-cleaned sandbox worktree: $worktree_path"
120+
else
121+
worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
122+
echo "[codex-agent] Sandbox worktree kept: $worktree_path"
123+
if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then
124+
echo "[codex-agent] If finished, merge + clean with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\""
125+
fi
126+
fi
127+
128+
exit "$codex_exit"

0 commit comments

Comments
 (0)