diff --git a/README.md b/README.md index c5f71be..eea933d 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,9 @@ Use this exact checklist to setup multi-agent safety in this repository for Code start isolated agent branch/worktree -> claim file locks -> implement/verify -> finish via PR/merge cleanup with scripts/agent-branch-finish.sh. - `scripts/codex-agent.sh` now auto-runs this finish flow after Codex exits: - auto-commit changed files -> push/create PR -> merge attempt -> branch/worktree cleanup -> - pull local base branch. + auto-commit changed files -> push/create PR -> merge attempt -> keep branch/worktree for follow-up. + - Remove merged branches when you are done reviewing: + gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)" 5) Optional: create OpenSpec planning workspace: bash scripts/openspec/init-plan-workspace.sh "" @@ -272,9 +273,11 @@ gx protect set [--target ] gx protect reset [--target ] gx sync --check [--target ] [--base ] [--json] gx sync [--target ] [--base ] [--strategy rebase|merge] [--ff-only] +gx cleanup [--target ] [--base ] [--branch ] [--dry-run] [--force-dirty] [--keep-remote] gx report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] -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/agent-worktree-prune.sh # prune temporary worktrees only (keeps merged agent branches by default) +bash scripts/agent-worktree-prune.sh --delete-branches --delete-remote-branches # full merged-branch cleanup +bash scripts/agent-worktree-prune.sh --force-dirty --delete-branches # force-remove dirty merged worktrees too bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` @@ -291,8 +294,10 @@ and asks `[y/N]` whether to update immediately (default is `N`). - `gx doctor` on protected `main` auto-starts an isolated `agent/gx/...-gx-doctor` worktree branch and applies repairs there. - `gx setup` and `gx doctor` always refresh `.githooks/pre-commit` from templates, so Codex sub-branch enforcement stays repaired. - `scripts/codex-agent.sh` now auto-runs finish automation after a Codex session when `origin` exists: - auto-commit changed files, run PR/merge cleanup, and prune merged worktrees. + auto-commit changed files, run PR/merge automation, and keep merged agent branches/worktrees by default. + It also auto-syncs each sandbox branch against the latest base branch before task execution. If conflicts remain, it keeps the sandbox and prompts for a conflict-resolution review pass. +- use `gx cleanup` (or `gx cleanup --branch `) to remove merged branches/worktrees when done. ## Advanced commands diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index b63f08b..37ab219 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -58,6 +58,8 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([ '.githooks/pre-commit', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/agent-worktree-prune.sh', + 'scripts/codex-agent.sh', 'scripts/agent-file-locks.py', ]); @@ -88,6 +90,7 @@ const COMMAND_TYPO_ALIASES = new Map([ ['intsall', 'install'], ['docter', 'doctor'], ['doctro', 'doctor'], + ['cleunup', 'cleanup'], ['scna', 'scan'], ]); const SUGGESTIBLE_COMMANDS = [ @@ -100,6 +103,7 @@ const SUGGESTIBLE_COMMANDS = [ 'copy-commands', 'protect', 'sync', + 'cleanup', 'release', 'install', 'fix', @@ -118,6 +122,7 @@ const CLI_COMMAND_DESCRIPTIONS = [ ['copy-commands', 'Print setup checklist as executable commands only'], ['protect', 'Manage protected branches (list/add/remove/set/reset)'], ['sync', 'Check or sync agent branches with origin/'], + ['cleanup', 'Cleanup merged agent branches/worktrees (local + remote)'], ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'], ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'], ['scan', 'Report safety issues and exit non-zero on findings'], @@ -152,6 +157,9 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup GuardeX (Guardian T-R - For every new user message/task, repeat the same cycle: start isolated agent branch/worktree -> claim file locks -> implement/verify -> finish via PR/merge cleanup with scripts/agent-branch-finish.sh. + - Finished branches stay available by default for audit/follow-up. + Remove them explicitly when done: + gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)" 5) Optional: create OpenSpec planning workspace: bash scripts/openspec/init-plan-workspace.sh "" @@ -174,6 +182,7 @@ bash scripts/codex-agent.sh "task" "agent-name" bash scripts/agent-branch-start.sh "task" "agent-name" python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" +gx cleanup --branch "$(git rev-parse --abbrev-ref HEAD)" bash scripts/openspec/init-plan-workspace.sh "" gx protect add release staging gx sync --check @@ -290,6 +299,8 @@ NOTES - ${TOOL_NAME} setup asks for Y/N approval before global installs - In initialized repos, setup/install/fix block in-place writes on protected main by default - doctor auto-starts a sandbox agent branch/worktree when run on protected main + - agent-branch-finish merges by default and keeps agent branches/worktrees until explicit cleanup + - use '${SHORT_TOOL_NAME} cleanup' to remove merged agent branches/worktrees (optionally remote refs too) - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`); if (outsideGitRepo) { @@ -506,7 +517,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', + 'agent:cleanup': `${SHORT_TOOL_NAME} cleanup`, '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', @@ -1256,6 +1267,63 @@ function parseSyncArgs(rawArgs) { return options; } +function parseCleanupArgs(rawArgs) { + const options = { + target: process.cwd(), + base: '', + branch: '', + dryRun: false, + forceDirty: false, + keepRemote: false, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires an agent branch value'); + } + options.branch = next; + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--force-dirty') { + options.forceDirty = true; + continue; + } + if (arg === '--keep-remote') { + options.keepRemote = true; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + return options; +} + function syncOperation(repoRoot, strategy, baseRef, ffOnly) { if (strategy === 'rebase') { if (ffOnly) { @@ -2284,6 +2352,39 @@ function copyCommands() { process.exitCode = 0; } +function cleanup(rawArgs) { + const options = parseCleanupArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const pruneScript = path.join(repoRoot, 'scripts', 'agent-worktree-prune.sh'); + if (!fs.existsSync(pruneScript)) { + throw new Error(`Missing cleanup script: ${pruneScript}. Run '${SHORT_TOOL_NAME} setup' first.`); + } + + const args = [pruneScript]; + if (options.base) { + args.push('--base', options.base); + } + if (options.branch) { + args.push('--branch', options.branch); + } + if (options.forceDirty) { + args.push('--force-dirty'); + } + if (options.dryRun) { + args.push('--dry-run'); + } + args.push('--delete-branches'); + if (!options.keepRemote) { + args.push('--delete-remote-branches'); + } + + const runResult = run('bash', args, { cwd: repoRoot, stdio: 'inherit' }); + if (runResult.status !== 0) { + throw new Error('Cleanup command failed'); + } + process.exitCode = 0; +} + function sync(rawArgs) { const options = parseSyncArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -2632,6 +2733,11 @@ function main() { return; } + if (command === 'cleanup') { + cleanup(rest); + return; + } + if (command === 'release') { release(rest); return; diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index a87cec4..17aa2f2 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -5,9 +5,26 @@ BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" PUSH_ENABLED=1 -DELETE_REMOTE_BRANCH=1 +DELETE_REMOTE_BRANCH=0 +DELETE_REMOTE_BRANCH_EXPLICIT=0 MERGE_MODE="auto" GH_BIN="${MUSAFETY_GH_BIN:-gh}" +CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}" + +normalize_bool() { + local raw="${1:-}" + local fallback="${2:-0}" + local lowered + lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$lowered" in + 1|true|yes|on) printf '1' ;; + 0|false|no|off) printf '0' ;; + '') printf '%s' "$fallback" ;; + *) printf '%s' "$fallback" ;; + esac +} + +CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")" while [[ $# -gt 0 ]]; do case "$1" in @@ -26,6 +43,20 @@ while [[ $# -gt 0 ]]; do ;; --keep-remote-branch) DELETE_REMOTE_BRANCH=0 + DELETE_REMOTE_BRANCH_EXPLICIT=1 + shift + ;; + --delete-remote-branch) + DELETE_REMOTE_BRANCH=1 + DELETE_REMOTE_BRANCH_EXPLICIT=1 + shift + ;; + --cleanup) + CLEANUP_AFTER_MERGE=1 + shift + ;; + --no-cleanup) + CLEANUP_AFTER_MERGE=0 shift ;; --mode) @@ -42,12 +73,16 @@ while [[ $# -gt 0 ]]; do ;; *) echo "[agent-branch-finish] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--branch ] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 exit 1 ;; esac done +if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then + DELETE_REMOTE_BRANCH=1 +fi + case "$MERGE_MODE" in auto|direct|pr) ;; *) @@ -347,43 +382,58 @@ if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true fi -if [[ "$source_worktree" == "$repo_root" ]]; then - if is_clean_worktree "$source_worktree"; then - git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true - if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then - git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true +base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" +if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then + git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true +fi + +if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then + if [[ "$source_worktree" == "$repo_root" ]]; then + if is_clean_worktree "$source_worktree"; then + git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true + if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true + fi fi + elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true fi -elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true -fi -if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true -fi + if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true + fi -git -C "$repo_root" branch -d "$SOURCE_BRANCH" + git -C "$repo_root" branch -d "$SOURCE_BRANCH" -if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then - if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then - git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" + if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then + git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" + fi fi -fi -base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" -if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then - git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true -fi + if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + prune_args=(--base "$BASE_BRANCH" --delete-branches) + if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + prune_args+=(--delete-remote-branches) + fi + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + fi + fi -if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then - echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 - echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2 + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." + if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 + echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + fi +else + if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 + fi fi -fi -echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch." -if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2 + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree." + echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" fi diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index ced73bc..7a08c28 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -5,6 +5,9 @@ BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 FORCE_DIRTY=0 +DELETE_BRANCHES=0 +DELETE_REMOTE_BRANCHES=0 +TARGET_BRANCH="" if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -25,9 +28,21 @@ while [[ $# -gt 0 ]]; do FORCE_DIRTY=1 shift ;; + --delete-branches) + DELETE_BRANCHES=1 + shift + ;; + --delete-remote-branches) + DELETE_REMOTE_BRANCHES=1 + shift + ;; + --branch) + TARGET_BRANCH="${2:-}" + shift 2 + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--branch ]" >&2 exit 1 ;; esac @@ -73,6 +88,11 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then exit 1 fi +if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then + echo "[agent-worktree-prune] --branch must reference an agent/* branch: ${TARGET_BRANCH}" >&2 + exit 1 +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then BASE_BRANCH="$(resolve_base_branch)" fi @@ -124,6 +144,10 @@ process_entry() { branch="${branch_ref#refs/heads/}" fi + if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then + return + fi + if [[ "$wt" == "$current_pwd" ]]; then skipped_active=$((skipped_active + 1)) echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}" @@ -138,7 +162,9 @@ process_entry() { remove_reason="missing-branch" elif [[ "$branch" == agent/* ]]; then if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then - remove_reason="merged-agent-branch" + if [[ "$DELETE_BRANCHES" -eq 1 ]]; then + remove_reason="merged-agent-branch" + fi fi elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then remove_reason="temporary-worktree" @@ -163,10 +189,16 @@ process_entry() { fi if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then - if [[ "$branch" == agent/* ]]; then + if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then 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 merged branch: ${branch}" + if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true + echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}" + fi + fi fi elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true @@ -199,18 +231,29 @@ done < <(git -C "$repo_root" worktree list --porcelain) process_entry "$current_wt" "$current_branch_ref" -while IFS= read -r branch; do - [[ -z "$branch" ]] && continue - if branch_has_worktree "$branch"; then - continue - fi - if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then - 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 merged branch: ${branch}" +if [[ "$DELETE_BRANCHES" -eq 1 ]]; then + while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then + continue fi - fi -done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) + if branch_has_worktree "$branch"; then + continue + fi + if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then + 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 merged branch: ${branch}" + if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true + echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}" + fi + fi + fi + fi + done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) +fi run_cmd git -C "$repo_root" worktree prune diff --git a/templates/AGENTS.multiagent-safety.md b/templates/AGENTS.multiagent-safety.md index a883a72..7e79035 100644 --- a/templates/AGENTS.multiagent-safety.md +++ b/templates/AGENTS.multiagent-safety.md @@ -11,7 +11,9 @@ - If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. - For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "" ""`. - Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active. -- Agent completion defaults to `scripts/codex-agent.sh`, which now auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, clean branch/worktree, and pull the local base branch after merge). +- Agent completion defaults to `scripts/codex-agent.sh`, which auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, and pull the local base branch after merge). +- Auto-finish keeps the sandbox branch/worktree by default so conflict follow-ups and audits stay reproducible. +- Use explicit cleanup when done: `gx cleanup --branch ""` (or `gx cleanup` for all merged agent branches). - If codex-agent auto-finish cannot complete, immediately run `scripts/agent-branch-finish.sh --branch "" --via-pr` and keep the branch open until checks/review pass. - If merge/rebase conflicts block auto-finish, run a conflict-resolution review pass in that sandbox branch, then rerun `agent-branch-finish.sh --via-pr` until merged. - Per-message loop is mandatory: for every new user message/task, start a fresh agent branch/worktree, claim ownership locks, implement and verify, finish via PR/merge cleanup, then repeat for the next message/task. diff --git a/templates/codex/skills/guardex/SKILL.md b/templates/codex/skills/guardex/SKILL.md index fbd3ab6..4d9f32f 100644 --- a/templates/codex/skills/guardex/SKILL.md +++ b/templates/codex/skills/guardex/SKILL.md @@ -36,4 +36,6 @@ gx scan - Keep agent work isolated (`agent/*` branches + lock claims). - For every new user message/task, restart the full loop on a fresh agent branch/worktree. - For one-command Codex sandbox startup, use `bash scripts/codex-agent.sh "" ""`. +- `scripts/codex-agent.sh` auto-syncs the sandbox branch against base before each task and auto-finishes merge/PR flow after Codex exits. +- Auto-finish keeps the branch/worktree by default; remove merged branches explicitly with `gx cleanup` (or `gx cleanup --branch ""`). - Do not bypass protected branch safeguards unless explicitly required. diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index a87cec4..17aa2f2 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -5,9 +5,26 @@ BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" PUSH_ENABLED=1 -DELETE_REMOTE_BRANCH=1 +DELETE_REMOTE_BRANCH=0 +DELETE_REMOTE_BRANCH_EXPLICIT=0 MERGE_MODE="auto" GH_BIN="${MUSAFETY_GH_BIN:-gh}" +CLEANUP_AFTER_MERGE_RAW="${MUSAFETY_FINISH_CLEANUP:-false}" + +normalize_bool() { + local raw="${1:-}" + local fallback="${2:-0}" + local lowered + lowered="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + case "$lowered" in + 1|true|yes|on) printf '1' ;; + 0|false|no|off) printf '0' ;; + '') printf '%s' "$fallback" ;; + *) printf '%s' "$fallback" ;; + esac +} + +CLEANUP_AFTER_MERGE="$(normalize_bool "$CLEANUP_AFTER_MERGE_RAW" "0")" while [[ $# -gt 0 ]]; do case "$1" in @@ -26,6 +43,20 @@ while [[ $# -gt 0 ]]; do ;; --keep-remote-branch) DELETE_REMOTE_BRANCH=0 + DELETE_REMOTE_BRANCH_EXPLICIT=1 + shift + ;; + --delete-remote-branch) + DELETE_REMOTE_BRANCH=1 + DELETE_REMOTE_BRANCH_EXPLICIT=1 + shift + ;; + --cleanup) + CLEANUP_AFTER_MERGE=1 + shift + ;; + --no-cleanup) + CLEANUP_AFTER_MERGE=0 shift ;; --mode) @@ -42,12 +73,16 @@ while [[ $# -gt 0 ]]; do ;; *) echo "[agent-branch-finish] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--branch ] [--no-push] [--keep-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 + echo "Usage: $0 [--base ] [--branch ] [--no-push] [--cleanup|--no-cleanup] [--keep-remote-branch|--delete-remote-branch] [--mode auto|direct|pr|--via-pr|--direct-only]" >&2 exit 1 ;; esac done +if [[ "$CLEANUP_AFTER_MERGE" -eq 1 && "$DELETE_REMOTE_BRANCH_EXPLICIT" -eq 0 ]]; then + DELETE_REMOTE_BRANCH=1 +fi + case "$MERGE_MODE" in auto|direct|pr) ;; *) @@ -347,43 +382,58 @@ if [[ -x "${repo_root}/scripts/agent-file-locks.py" ]]; then python3 "${repo_root}/scripts/agent-file-locks.py" release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true fi -if [[ "$source_worktree" == "$repo_root" ]]; then - if is_clean_worktree "$source_worktree"; then - git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true - if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then - git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true +base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" +if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then + git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true +fi + +if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then + if [[ "$source_worktree" == "$repo_root" ]]; then + if is_clean_worktree "$source_worktree"; then + git -C "$source_worktree" checkout "$BASE_BRANCH" >/dev/null 2>&1 || true + if [[ "$PUSH_ENABLED" -eq 1 ]] && git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$source_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true + fi fi + elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true fi -elif [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - git -C "$source_worktree" checkout --detach >/dev/null 2>&1 || true -fi -if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true -fi + if [[ "$source_worktree" != "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + git -C "$repo_root" worktree remove "$source_worktree" --force >/dev/null 2>&1 || true + fi -git -C "$repo_root" branch -d "$SOURCE_BRANCH" + git -C "$repo_root" branch -d "$SOURCE_BRANCH" -if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then - if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then - git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" + if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then + git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" + fi fi -fi -base_worktree="$(get_worktree_for_branch "$BASE_BRANCH")" -if [[ -n "$base_worktree" ]] && is_clean_worktree "$base_worktree" && [[ "$PUSH_ENABLED" -eq 1 ]]; then - git -C "$base_worktree" pull --ff-only origin "$BASE_BRANCH" >/dev/null 2>&1 || true -fi + if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + prune_args=(--base "$BASE_BRANCH" --delete-branches) + if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then + prune_args+=(--delete-remote-branches) + fi + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then + echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 + echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + fi + fi -if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then - if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then - echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2 - echo "[agent-branch-finish] You can run manual cleanup: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2 + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree." + if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then + echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 + echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches" >&2 + fi +else + if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then + if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" --base "$BASE_BRANCH"; then + echo "[agent-branch-finish] Warning: temporary worktree prune failed." >&2 + fi fi -fi -echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and removed branch." -if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${repo_root}/.omx/agent-worktrees"/* ]]; then - echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2 - echo "[agent-branch-finish] Leave this directory, then run: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH}" >&2 + echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and kept source branch/worktree." + echo "[agent-branch-finish] Cleanup later with: bash scripts/agent-worktree-prune.sh --base ${BASE_BRANCH} --delete-branches --delete-remote-branches" fi diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index ced73bc..7a08c28 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -5,6 +5,9 @@ BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" BASE_BRANCH_EXPLICIT=0 DRY_RUN=0 FORCE_DIRTY=0 +DELETE_BRANCHES=0 +DELETE_REMOTE_BRANCHES=0 +TARGET_BRANCH="" if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -25,9 +28,21 @@ while [[ $# -gt 0 ]]; do FORCE_DIRTY=1 shift ;; + --delete-branches) + DELETE_BRANCHES=1 + shift + ;; + --delete-remote-branches) + DELETE_REMOTE_BRANCHES=1 + shift + ;; + --branch) + TARGET_BRANCH="${2:-}" + shift 2 + ;; *) echo "[agent-worktree-prune] Unknown argument: $1" >&2 - echo "Usage: $0 [--base ] [--dry-run] [--force-dirty]" >&2 + echo "Usage: $0 [--base ] [--dry-run] [--force-dirty] [--delete-branches] [--delete-remote-branches] [--branch ]" >&2 exit 1 ;; esac @@ -73,6 +88,11 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then exit 1 fi +if [[ -n "$TARGET_BRANCH" && "$TARGET_BRANCH" != agent/* ]]; then + echo "[agent-worktree-prune] --branch must reference an agent/* branch: ${TARGET_BRANCH}" >&2 + exit 1 +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then BASE_BRANCH="$(resolve_base_branch)" fi @@ -124,6 +144,10 @@ process_entry() { branch="${branch_ref#refs/heads/}" fi + if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then + return + fi + if [[ "$wt" == "$current_pwd" ]]; then skipped_active=$((skipped_active + 1)) echo "[agent-worktree-prune] Skipping active cwd worktree: ${wt}" @@ -138,7 +162,9 @@ process_entry() { remove_reason="missing-branch" elif [[ "$branch" == agent/* ]]; then if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then - remove_reason="merged-agent-branch" + if [[ "$DELETE_BRANCHES" -eq 1 ]]; then + remove_reason="merged-agent-branch" + fi fi elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then remove_reason="temporary-worktree" @@ -163,10 +189,16 @@ process_entry() { fi if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch}" && ! branch_has_worktree "$branch"; then - if [[ "$branch" == agent/* ]]; then + if [[ "$branch" == agent/* && "$DELETE_BRANCHES" -eq 1 ]]; then 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 merged branch: ${branch}" + if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true + echo "[agent-worktree-prune] Deleted merged remote branch: ${branch}" + fi + fi fi elif [[ "$branch" == __agent_integrate_* || "$branch" == __source-probe-* ]]; then run_cmd git -C "$repo_root" branch -D "$branch" >/dev/null 2>&1 || true @@ -199,18 +231,29 @@ done < <(git -C "$repo_root" worktree list --porcelain) process_entry "$current_wt" "$current_branch_ref" -while IFS= read -r branch; do - [[ -z "$branch" ]] && continue - if branch_has_worktree "$branch"; then - continue - fi - if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then - 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 merged branch: ${branch}" +if [[ "$DELETE_BRANCHES" -eq 1 ]]; then + while IFS= read -r branch; do + [[ -z "$branch" ]] && continue + if [[ -n "$TARGET_BRANCH" && "$branch" != "$TARGET_BRANCH" ]]; then + continue fi - fi -done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) + if branch_has_worktree "$branch"; then + continue + fi + if git -C "$repo_root" merge-base --is-ancestor "$branch" "$BASE_BRANCH"; then + 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 merged branch: ${branch}" + if [[ "$DELETE_REMOTE_BRANCHES" -eq 1 ]]; then + if git -C "$repo_root" ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + run_cmd git -C "$repo_root" push origin --delete "$branch" >/dev/null 2>&1 || true + echo "[agent-worktree-prune] Deleted stale merged remote branch: ${branch}" + fi + fi + fi + fi + done < <(git -C "$repo_root" for-each-ref --format='%(refname:short)' refs/heads/agent) +fi run_cmd git -C "$repo_root" worktree prune diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 09cacf5..136c59c 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -8,6 +8,7 @@ BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}" AUTO_FINISH_RAW="${MUSAFETY_CODEX_AUTO_FINISH:-true}" AUTO_REVIEW_ON_CONFLICT_RAW="${MUSAFETY_CODEX_AUTO_REVIEW_ON_CONFLICT:-true}" +AUTO_CLEANUP_RAW="${MUSAFETY_CODEX_AUTO_CLEANUP:-false}" normalize_bool() { local raw="${1:-}" @@ -24,6 +25,7 @@ normalize_bool() { AUTO_FINISH="$(normalize_bool "$AUTO_FINISH_RAW" "1")" AUTO_REVIEW_ON_CONFLICT="$(normalize_bool "$AUTO_REVIEW_ON_CONFLICT_RAW" "1")" +AUTO_CLEANUP="$(normalize_bool "$AUTO_CLEANUP_RAW" "0")" if [[ -n "$BASE_BRANCH" ]]; then BASE_BRANCH_EXPLICIT=1 @@ -64,6 +66,14 @@ while [[ $# -gt 0 ]]; do AUTO_REVIEW_ON_CONFLICT=0 shift ;; + --cleanup) + AUTO_CLEANUP=1 + shift + ;; + --no-cleanup) + AUTO_CLEANUP=0 + shift + ;; --) shift break @@ -133,6 +143,75 @@ has_origin_remote() { git -C "$repo_root" remote get-url origin >/dev/null 2>&1 } +resolve_worktree_base_branch() { + local wt="$1" + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then + printf '%s' "$BASE_BRANCH" + return 0 + fi + + local branch + branch="$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + return 0 + fi + + local stored_base + stored_base="$(git -C "$repo_root" config --get "branch.${branch}.musafetyBase" || true)" + if [[ -n "$stored_base" ]]; then + printf '%s' "$stored_base" + return 0 + fi + + local configured_base + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]]; then + printf '%s' "$configured_base" + fi +} + +sync_worktree_with_base() { + local wt="$1" + if ! has_origin_remote; then + return 0 + fi + + local base_branch + base_branch="$(resolve_worktree_base_branch "$wt")" + if [[ -z "$base_branch" ]]; then + return 0 + fi + + if ! git -C "$wt" fetch origin "$base_branch" --quiet; then + echo "[codex-agent] Warning: could not fetch origin/${base_branch} before task start." >&2 + return 0 + fi + + if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + return 0 + fi + + local behind_count + behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')" + behind_count="${behind_count:-0}" + if [[ "$behind_count" -le 0 ]]; then + return 0 + fi + + local branch + branch="$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + echo "[codex-agent] Task sync: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Rebasing before launch..." + if ! git -C "$wt" rebase "origin/${base_branch}"; then + echo "[codex-agent] Task sync failed. Resolve and continue in sandbox:" >&2 + echo " git -C \"$wt\" rebase --continue" >&2 + echo " # or abort" >&2 + echo " git -C \"$wt\" rebase --abort" >&2 + return 1 + fi + echo "[codex-agent] Task sync complete." + return 0 +} + worktree_has_changes() { local wt="$1" if ! git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json"; then @@ -224,6 +303,9 @@ run_finish_flow() { if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then finish_args+=(--base "$BASE_BRANCH") fi + if [[ "$AUTO_CLEANUP" -eq 1 ]]; then + finish_args+=(--cleanup) + fi if has_origin_remote; then if command -v gh >/dev/null 2>&1 || command -v "${MUSAFETY_GH_BIN:-gh}" >/dev/null 2>&1; then @@ -268,6 +350,10 @@ run_finish_flow() { return 1 } +if ! sync_worktree_with_base "$worktree_path"; then + exit 1 +fi + echo "[codex-agent] Launching ${CODEX_BIN} in sandbox: $worktree_path" cd "$worktree_path" set +e @@ -282,7 +368,11 @@ auto_finish_completed=0 worktree_branch="$(git -C "$worktree_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" if [[ "$AUTO_FINISH" -eq 1 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then - echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge -> cleanup." + if [[ "$AUTO_CLEANUP" -eq 1 ]]; then + echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge -> cleanup." + else + echo "[codex-agent] Auto-finish enabled: commit -> push/PR -> merge (keep branch/worktree)." + fi if auto_commit_worktree_changes "$worktree_path" "$worktree_branch"; then if run_finish_flow "$worktree_path" "$worktree_branch"; then auto_finish_completed=1 @@ -311,6 +401,9 @@ if [[ -x "${repo_root}/scripts/agent-worktree-prune.sh" ]]; then if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then prune_args+=(--base "$BASE_BRANCH") fi + if [[ "$AUTO_CLEANUP" -eq 1 ]]; then + prune_args+=(--delete-branches --delete-remote-branches) + fi if ! bash "${repo_root}/scripts/agent-worktree-prune.sh" "${prune_args[@]}"; then echo "[codex-agent] Warning: automatic worktree cleanup failed." >&2 fi @@ -321,8 +414,13 @@ if [[ ! -d "$worktree_path" ]]; then 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 [[ "$auto_finish_completed" -eq 0 && -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then - echo "[codex-agent] If finished, merge + clean with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --via-pr" + if [[ -n "$worktree_branch" && "$worktree_branch" != "HEAD" ]]; then + if [[ "$auto_finish_completed" -eq 1 ]]; then + echo "[codex-agent] Branch kept intentionally. Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" + else + echo "[codex-agent] If finished, merge with: bash scripts/agent-branch-finish.sh --branch \"${worktree_branch}\" --via-pr" + echo "[codex-agent] Cleanup on demand: gx cleanup --branch \"${worktree_branch}\"" + fi fi fi diff --git a/test/install.test.js b/test/install.test.js index 5ddb241..fe8c57f 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'); + assert.equal(packageJson.scripts['agent:cleanup'], 'gx cleanup'); const agentsContent = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); assert.equal(agentsContent.includes(''), true); @@ -554,7 +554,7 @@ test('agent-branch-finish infers base from source branch metadata and updates ma ); const localBranchExists = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${agentBranch}`], repoDir); - assert.equal(localBranchExists.status, 1, localBranchExists.stderr || localBranchExists.stdout); + assert.equal(localBranchExists.status, 0, localBranchExists.stderr || localBranchExists.stdout); }); test('default invocation runs non-mutating status output', () => { @@ -743,7 +743,7 @@ 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 and auto-prunes clean branches on exit', () => { +test('codex-agent launches codex inside a fresh sandbox worktree and keeps branch/worktree by default', () => { const repoDir = initRepo(); seedCommit(repoDir); @@ -782,7 +782,7 @@ test('codex-agent launches codex inside a fresh sandbox worktree and auto-prunes 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:/); + assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -793,10 +793,10 @@ test('codex-agent launches codex inside a fresh sandbox worktree and auto-prunes const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); - assert.equal(fs.existsSync(launchedCwd), false, 'clean codex-agent sandbox should auto-prune on exit'); + assert.equal(fs.existsSync(launchedCwd), true, 'clean codex-agent sandbox should stay available by default'); 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'); + assert.equal(branchResult.status, 0, 'agent branch should remain after default codex-agent run'); }); test('codex-agent supports --codex-bin override before positional arguments', () => { @@ -845,7 +845,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:/); + assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); assert.match( @@ -854,7 +854,7 @@ 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'); + assert.equal(fs.existsSync(launchedCwd), true, 'override invocation should keep sandbox unless cleanup is requested'); }); test('codex-agent keeps dirty sandbox worktrees after session exit', () => { @@ -961,17 +961,17 @@ exit 1 }, ); assert.equal(launch.status, 0, launch.stderr || launch.stdout); - assert.match(launch.stdout, /\[codex-agent\] Auto-finish enabled: commit -> push\/PR -> merge -> cleanup\./); + assert.match(launch.stdout, /\[codex-agent\] Auto-finish enabled: commit -> push\/PR -> merge \(keep branch\/worktree\)\./); assert.match(launch.stdout, /\[codex-agent\] Auto-finish completed for/); - assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); + assert.match(launch.stdout, /\[codex-agent\] Sandbox worktree kept:/); const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); - assert.equal(fs.existsSync(launchedCwd), false, 'auto-finished sandbox should be removed'); + assert.equal(fs.existsSync(launchedCwd), true, 'auto-finished sandbox should stay until explicit cleanup'); const launchedBranch = extractCreatedBranch(launch.stdout); result = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${launchedBranch}`], repoDir); - assert.notEqual(result.status, 0, 'auto-finished branch should be removed locally'); + assert.equal(result.status, 0, 'auto-finished branch should remain locally by default'); result = runCmd('git', ['ls-remote', '--heads', 'origin', launchedBranch], repoDir); - assert.equal(result.stdout.trim(), '', 'auto-finished branch should be removed on origin'); + assert.match(result.stdout, /refs\/heads\//, 'auto-finished branch should remain on origin by default'); const launchedArgs = fs.readFileSync(argsMarker, 'utf8').trim(); assert.match(launchedArgs, /--model gpt-5\.4-mini/); @@ -1157,11 +1157,11 @@ test('agent-branch-finish auto-syncs source branch when behind origin/dev', () = assert.match(finish.stderr, /Auto-sync complete \(behind now: 0\)/); assert.match( finish.stdout, - /Merged 'agent\/test-finish-sync-guard' into 'dev' via direct flow and removed branch\./, + /Merged 'agent\/test-finish-sync-guard' into 'dev' via direct flow and kept source branch\/worktree\./, ); result = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-finish-sync-guard'], repoDir); - assert.notEqual(result.status, 0, 'agent branch should be deleted locally after finish'); + assert.equal(result.status, 0, 'agent branch should stay locally after finish by default'); }); test('agent-branch-finish pr mode continues cleanup when gh merge only fails local branch deletion', () => { @@ -1207,7 +1207,7 @@ exit 1 const finish = runCmd( 'bash', - ['scripts/agent-branch-finish.sh', '--branch', 'agent/test-pr-delete-error', '--mode', 'pr'], + ['scripts/agent-branch-finish.sh', '--branch', 'agent/test-pr-delete-error', '--mode', 'pr', '--cleanup'], repoDir, { MUSAFETY_GH_BIN: fakeGhPath }, ); @@ -1218,7 +1218,7 @@ exit 1 ); assert.match( finish.stdout, - /Merged 'agent\/test-pr-delete-error' into 'dev' via pr flow and removed branch\./, + /Merged 'agent\/test-pr-delete-error' into 'dev' via pr flow and cleaned source branch\/worktree\./, ); result = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-pr-delete-error'], repoDir); @@ -1527,7 +1527,7 @@ exit 1 assert.equal(args, 'i -g @fission-ai/openspec @imdeadpool/codex-account-switcher'); }); -test('worktree prune removes merged agent worktrees and branches', () => { +test('worktree prune keeps merged agent worktrees/branches unless delete flags are set', () => { const repoDir = initRepo(); let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -1540,10 +1540,15 @@ test('worktree prune removes merged agent worktrees and branches', () => { result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); - assert.equal(fs.existsSync(worktreePath), false); const branchResult = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-prune'], repoDir); - assert.notEqual(branchResult.status, 0, 'merged agent branch should be removed by prune'); + assert.equal(branchResult.status, 0, 'merged agent branch should remain by default'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--delete-branches'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(worktreePath), false); + const branchAfterDelete = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-prune'], repoDir); + assert.notEqual(branchAfterDelete.status, 0, 'merged agent branch should be removed when delete flag is set'); }); test('worktree prune preserves dirty agent worktrees unless --force-dirty is used', () => { @@ -1558,16 +1563,47 @@ test('worktree prune preserves dirty agent worktrees unless --force-dirty is use fs.writeFileSync(path.join(worktreePath, 'dirty.txt'), 'dirty\n', 'utf8'); - result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--delete-branches'], 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); + result = runCmd('bash', ['scripts/agent-worktree-prune.sh', '--force-dirty', '--delete-branches'], 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('cleanup command removes merged agent branch/worktree and remote ref', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + attachOriginRemote(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + 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); + result = runCmd('git', ['push', 'origin', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'agent__cleanup-branch'); + result = runCmd('git', ['worktree', 'add', '-b', 'agent/test-cleanup', worktreePath, 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['-C', worktreePath, 'push', '-u', 'origin', 'agent/test-cleanup'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['cleanup', '--target', repoDir, '--branch', 'agent/test-cleanup'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const localBranch = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/test-cleanup'], repoDir); + assert.notEqual(localBranch.status, 0, 'cleanup should remove local branch'); + const remoteBranch = runCmd('git', ['ls-remote', '--heads', 'origin', 'agent/test-cleanup'], repoDir); + assert.equal(remoteBranch.stdout.trim(), '', 'cleanup should remove remote branch'); + assert.equal(fs.existsSync(worktreePath), false, 'cleanup should remove worktree'); +}); + test('release fails outside the maintainer repo path', () => { const repoDir = initRepoOnBranch('main'); const result = runNode(['release'], repoDir);