From cd2f84efecd54da6c86744b5bcc3a3e803ad7454 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 11 Apr 2026 19:14:56 +0200 Subject: [PATCH] Keep main-focused VS Code workflows clean by auto-pruning finished agent worktrees Codex sessions now return to repo root and run worktree cleanup on exit, so finished clean agent branches disappear automatically instead of piling up in VS Code Source Control.\n\nPrune now infers base branch when omitted, preserves dirty worktrees by default, and adds a force flag for explicit cleanup. Documentation and tests were updated to match the GuardeX workflow and protect against regressions. Constraint: Users keep their local checkout on main/dev while Codex works in isolated agent/* branches\nRejected: Force-delete every agent worktree on exit | risks data loss for dirty/unmerged work\nConfidence: high\nScope-risk: moderate\nReversibility: clean\nDirective: Keep dirty-worktree preservation as default safety behavior; only remove with --force-dirty\nTested: npm test\nTested: node --check bin/multiagent-safety.js\nNot-tested: Manual VS Code Source Control screenshot validation after a full PR merge loop --- README.md | 11 ++- bin/multiagent-safety.js | 2 +- scripts/agent-worktree-prune.sh | 76 ++++++++++++++++++- templates/scripts/agent-worktree-prune.sh | 76 ++++++++++++++++++- templates/scripts/codex-agent.sh | 40 +++++++++- test/install.test.js | 90 +++++++++++++++++++++-- 6 files changed, 274 insertions(+), 21 deletions(-) mode change 100644 => 100755 templates/scripts/agent-worktree-prune.sh diff --git a/README.md b/README.md index ab89f72..8a13e98 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,15 @@ agent_bot_-- That gives you one stable main repo view plus parallel agent worktrees in the same VS Code window, so branch ownership and progress stay visible at once. -## Companion tool: `codex-auth` account switcher +## GuardeX dependency: `codex-auth` account switcher If you run multiple Codex identities, this workflow pairs well with -[`codex-auth`](https://github.com/recodeecom/codex-account-switcher-cli/tree/main), +[`codex-auth`](https://github.com/recodeecom/codex-account-switcher-cli), a CLI that snapshots `~/.codex/auth.json` per account and lets you switch fast without repeated login/logout loops. +For multi-identity workflows, treat `codex-auth` as a GuardeX dependency. + > [!WARNING] > Not affiliated with OpenAI or Codex. Not an official tool. @@ -270,10 +272,13 @@ gx protect reset [--target ] gx sync --check [--target ] [--base ] [--json] gx sync [--target ] [--base ] [--strategy rebase|merge] [--ff-only] gx report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] -bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanup +bash scripts/agent-worktree-prune.sh # manual stale worktree cleanup (auto-detects base) bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` +`scripts/codex-agent.sh` auto-runs worktree cleanup after each Codex session exit. +If a branch still appears in VS Code, it is usually still dirty/unmerged (kept intentionally). + No command defaults to `gx status` (non-mutating health/status view). `gx status` reports CLI/runtime info, global OMX/OpenSpec/codex-auth service status, and repo safety service state. `gx init` is an alias of `gx setup`. diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index efb6fa8..8ac20ed 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -489,7 +489,7 @@ function ensurePackageScripts(repoRoot, dryRun) { 'agent:codex': 'bash ./scripts/codex-agent.sh', 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', - 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh --base dev', + 'agent:cleanup': 'bash ./scripts/agent-worktree-prune.sh', 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh', 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim', 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete', diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index 170664a..ced73bc 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -1,22 +1,33 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" +BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 +FORCE_DIRTY=0 + +if [[ -n "$BASE_BRANCH" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi while [[ $# -gt 0 ]]; do case "$1" in --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --dry-run) DRY_RUN=1 shift ;; + --force-dirty) + FORCE_DIRTY=1 + shift + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 exit 1 ;; esac @@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +resolve_base_branch() { + local configured="" + local current="" + + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then + printf '%s' "$configured" + return 0 + fi + + current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then + printf '%s' "$current" + return 0 + fi + + for fallback in main dev; do + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "" +} + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] Unable to infer base branch. Pass --base ." >&2 + exit 1 +fi + if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 exit 1 @@ -49,9 +100,17 @@ branch_has_worktree() { git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" } +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" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + removed_worktrees=0 removed_branches=0 skipped_active=0 +skipped_dirty=0 process_entry() { local wt="$1" @@ -89,6 +148,12 @@ process_entry() { return 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}" + return + fi + echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}" run_cmd git -C "$repo_root" worktree remove "$wt" --force removed_worktrees=$((removed_worktrees + 1)) @@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads run_cmd git -C "$repo_root" worktree prune -echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}" +echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}" if [[ "$skipped_active" -gt 0 ]]; then echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2 fi +if [[ "$skipped_dirty" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2 +fi diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh old mode 100644 new mode 100755 index 170664a..ced73bc --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -1,22 +1,33 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" +BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 +FORCE_DIRTY=0 + +if [[ -n "$BASE_BRANCH" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi while [[ $# -gt 0 ]]; do case "$1" in --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --dry-run) DRY_RUN=1 shift ;; + --force-dirty) + FORCE_DIRTY=1 + shift + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 exit 1 ;; esac @@ -31,6 +42,46 @@ repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +resolve_base_branch() { + local configured="" + local current="" + + configured="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${configured}"; then + printf '%s' "$configured" + return 0 + fi + + current="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current" && "$current" != "HEAD" ]] && git -C "$repo_root" show-ref --verify --quiet "refs/heads/${current}"; then + printf '%s' "$current" + return 0 + fi + + for fallback in main dev; do + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "" +} + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch)" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-worktree-prune] Unable to infer base branch. Pass --base ." >&2 + exit 1 +fi + if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then echo "[agent-worktree-prune] Base branch not found: ${BASE_BRANCH}" >&2 exit 1 @@ -49,9 +100,17 @@ branch_has_worktree() { git -C "$repo_root" worktree list --porcelain | grep -q "^branch refs/heads/${branch}$" } +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" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + removed_worktrees=0 removed_branches=0 skipped_active=0 +skipped_dirty=0 process_entry() { local wt="$1" @@ -89,6 +148,12 @@ process_entry() { return 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}" + return + fi + echo "[agent-worktree-prune] Removing worktree (${remove_reason}): ${wt}" run_cmd git -C "$repo_root" worktree remove "$wt" --force removed_worktrees=$((removed_worktrees + 1)) @@ -149,7 +214,10 @@ done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads run_cmd git -C "$repo_root" worktree prune -echo "[agent-worktree-prune] Summary: removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}" +echo "[agent-worktree-prune] Summary: base=${BASE_BRANCH}, removed_worktrees=${removed_worktrees}, removed_branches=${removed_branches}, skipped_active=${skipped_active}, skipped_dirty=${skipped_dirty}" if [[ "$skipped_active" -gt 0 ]]; then echo "[agent-worktree-prune] Tip: leave active agent worktree directories, then run this command again for full cleanup." >&2 fi +if [[ "$skipped_dirty" -gt 0 ]]; then + echo "[agent-worktree-prune] Tip: dirty worktrees were preserved. Clean/finish them first, or pass --force-dirty to remove anyway." >&2 +fi diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index a565f78..370d2d5 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -65,7 +65,13 @@ if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then exit 127 fi -if [[ ! -x "scripts/agent-branch-start.sh" ]]; then +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[codex-agent] Not inside a git repository." >&2 + exit 1 +fi +repo_root="$(git rev-parse --show-toplevel)" + +if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 exit 1 fi @@ -75,7 +81,7 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") fi -start_output="$(bash scripts/agent-branch-start.sh "${start_args[@]}")" +start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}")" printf '%s\n' "$start_output" worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" @@ -91,4 +97,32 @@ fi echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" -exec "$CODEX_BIN" "$@" +set +e +"$CODEX_BIN" "$@" +codex_exit="$?" +set -e + +cd "$repo_root" + +if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + echo "[codex-agent] Session ended (exit=${codex_exit}). Running worktree cleanup..." + prune_args=() + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + prune_args+=(--base "$BASE_BRANCH") + fi + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then + echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 + fi +fi + +if [[ ! -d "$worktree_path" ]]; then + echo "[codex-agent] Auto-cleaned sandbox worktree: $worktree_path" +else + worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + echo "[codex-agent] Sandbox worktree kept: $worktree_path" + if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then + echo "[codex-agent] If finished, merge + clean with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\"" + fi +fi + +exit "$codex_exit" diff --git a/test/install.test.js b/test/install.test.js index 68ef3ad..d9913e8 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -216,7 +216,7 @@ test('setup provisions workflow files and repo config', () => { assert.equal(packageJson.scripts['agent:branch:sync'], 'gx sync'); assert.equal(packageJson.scripts['agent:branch:sync:check'], 'gx sync --check'); assert.equal(packageJson.scripts['agent:safety:setup'], 'gx setup'); - assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh --base dev'); + assert.equal(packageJson.scripts['agent:cleanup'], 'bash ./scripts/agent-worktree-prune.sh'); const agentsContent = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); assert.equal(agentsContent.includes(''), true); @@ -681,12 +681,18 @@ test('pre-commit blocks protected branch commits even from VS Code Source Contro assert.match(hookResult.stderr, /Direct commits on protected branches are blocked/); }); -test('codex-agent launches codex inside a fresh sandbox worktree', () => { +test('codex-agent launches codex inside a fresh sandbox worktree and auto-prunes clean branches on exit', () => { const repoDir = initRepo(); seedCommit(repoDir); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let setupCommit = runCmd('git', ['add', '.'], repoDir); + assert.equal(setupCommit.status, 0, setupCommit.stderr); + setupCommit = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(setupCommit.status, 0, setupCommit.stderr || setupCommit.stdout); const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-')); const fakeCodexPath = path.join(fakeBin, 'codex'); @@ -713,6 +719,8 @@ test('codex-agent launches codex inside a fresh sandbox worktree', () => { ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[codex-agent\] Launching codex in sandbox:/); + assert.match(launch.stdout, /\[codex-agent\] Session ended \(exit=0\)\. Running worktree cleanup\.\.\./); + assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -723,9 +731,10 @@ test('codex-agent launches codex inside a fresh sandbox worktree', () => { const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); - const branchResult = runCmd('git', ['-C', launchedCwd, 'branch', '--show-current'], repoDir); - assert.equal(branchResult.status, 0, branchResult.stderr || branchResult.stdout); - assert.match(branchResult.stdout.trim(), /^agent\/planner\//); + assert.equal(fs.existsSync(launchedCwd), false, 'clean sandbox should be auto-pruned after codex exit'); + const launchedBranch = extractCreatedBranch(launch.stdout); + const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${launchedBranch}`], repoDir); + assert.notEqual(branchResult.status, 0, 'clean auto-pruned branch should be removed locally'); }); test('codex-agent supports --codex-bin override before positional arguments', () => { @@ -734,6 +743,12 @@ test('codex-agent supports --codex-bin override before positional arguments', () const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let setupCommit = runCmd('git', ['add', '.'], repoDir); + assert.equal(setupCommit.status, 0, setupCommit.stderr); + setupCommit = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(setupCommit.status, 0, setupCommit.stderr || setupCommit.stdout); const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-bin-')); const fakeCodexPath = path.join(fakeBin, 'my-codex'); @@ -768,6 +783,7 @@ test('codex-agent supports --codex-bin override before positional arguments', () ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); assert.match(launch.stdout, /\[codex-agent\] Launching .* in sandbox:/); + assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -776,6 +792,47 @@ test('codex-agent supports --codex-bin override before positional arguments', () ); const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); + assert.equal(fs.existsSync(launchedCwd), false, 'clean override run should auto-prune sandbox'); +}); + +test('codex-agent keeps dirty sandbox worktrees after session exit', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-dirty-')); + const fakeCodexPath = path.join(fakeBin, 'codex'); + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env bash\n` + + `pwd > "${'${MUSAFETY_TEST_CODEX_CWD}'}"\n` + + `echo "$@" > "${'${MUSAFETY_TEST_CODEX_ARGS}'}"\n` + + `echo "dirty" > codex-dirty.txt\n`, + 'utf8', + ); + fs.chmodSync(fakeCodexPath, 0o755); + + const cwdMarker = path.join(repoDir, '.codex-agent-cwd-dirty'); + const argsMarker = path.join(repoDir, '.codex-agent-args-dirty'); + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'dirty-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], + repoDir, + { + PATH: `${fakeBin}:${process.env.PATH}`, + MUSAFETY_TEST_CODEX_CWD: cwdMarker, + MUSAFETY_TEST_CODEX_ARGS: argsMarker, + }, + ); + assert.equal(launch.status, 0, launch.stderr || launch.stdout); + assert.match(launch.stdout, /\[agent-worktree-prune\] Skipping dirty worktree/); + assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); + + const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); + assert.equal(fs.existsSync(launchedCwd), true, 'dirty sandbox should be preserved'); + assert.equal(fs.existsSync(path.join(launchedCwd, 'codex-dirty.txt')), true); }); test('sync command rebases current agent branch onto latest origin/dev', () => { @@ -1335,7 +1392,7 @@ test('worktree prune removes merged agent worktrees and branches', () => { assert.equal(result.status, 0, result.stderr); assert.equal(fs.existsSync(worktreePath), true); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--base', 'dev'], repoDir); + result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(fs.existsSync(worktreePath), false); @@ -1343,6 +1400,27 @@ test('worktree prune removes merged agent worktrees and branches', () => { assert.notEqual(branchResult.status, 0, 'merged agent branch should be removed by prune'); }); +test('worktree prune preserves dirty agent worktrees unless --force-dirty is used', () => { + 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-dirty-prune'); + result = runCmd('git', ['worktree', 'add', '-b', 'agent/test-dirty-prune', worktreePath, 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr); + fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'dirty\n', 'utf8'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /skipped_dirty=1/); + assert.equal(fs.existsSync(worktreePath), true, 'dirty worktree should remain without --force-dirty'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--force-dirty'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(worktreePath), false, 'dirty worktree should be removable with --force-dirty'); +}); + test('release fails outside the maintainer repo path', () => { const repoDir = initRepoOnBranch('main'); const result = runNode(['release'], repoDir);