From 8a4e4b76b659ed4e642918ed44ccc918ef873a05 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 11 Apr 2026 19:24:46 +0200 Subject: [PATCH] Keep local main pull-only by automatically cleaning finished agent worktrees 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 --- README.md | 4 +- 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 | 91 +++++++++++++++++++++-- 6 files changed, 270 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ab89f72..34b9098 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,8 @@ 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 base detection) +bash scripts/agent-worktree-prune.sh --force-dirty # remove stale dirty worktrees too bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` @@ -284,6 +285,7 @@ and asks `[y/N]` whether to update immediately (default is `N`). - Interactive prompt is strict (`[y/n]`) and waits for explicit answer. - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force. - 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. +- `scripts/codex-agent.sh` now auto-runs worktree prune after a Codex session; clean sandbox branches are removed automatically, dirty ones are kept. ## Advanced commands 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 index 170664a..ced73bc 100644 --- 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..8197e39 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 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); 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 codex-agent sandbox should auto-prune on 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 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); 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, 'override invocation should still auto-prune clean 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,28 @@ 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);