diff --git a/scripts/agent-worktree-prune.sh b/scripts/agent-worktree-prune.sh index ee82df1..3d51f3a 100755 --- a/scripts/agent-worktree-prune.sh +++ b/scripts/agent-worktree-prune.sh @@ -61,6 +61,11 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +repo_common_dir="$( + git -C "$repo_root" rev-parse --git-common-dir \ + | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' +)" +repo_common_dir="$(cd "$repo_common_dir" && pwd -P)" resolve_base_branch() { local configured="" @@ -132,11 +137,98 @@ is_clean_worktree() { && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] } +resolve_worktree_common_dir() { + local wt="$1" + local common_dir="" + common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -z "$common_dir" ]]; then + return 1 + fi + if [[ "$common_dir" == /* ]]; then + common_dir="$(cd "$common_dir" 2>/dev/null && pwd -P || true)" + else + common_dir="$(cd "$wt/$common_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$common_dir" ]]; then + return 1 + fi + printf '%s' "$common_dir" +} + +select_unique_worktree_path() { + local root="$1" + local name="$2" + local candidate="${root}/${name}" + local suffix=2 + while [[ -e "$candidate" ]]; do + candidate="${root}/${name}-${suffix}" + suffix=$((suffix + 1)) + done + printf '%s' "$candidate" +} + +relocated_foreign=0 +skipped_foreign=0 + +relocate_foreign_worktree_entries() { + [[ -d "$worktree_root" ]] || return 0 + + local entry="" + for entry in "${worktree_root}"/*; do + [[ -d "$entry" ]] || continue + if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi + + local entry_common_dir="" + entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" + [[ -n "$entry_common_dir" ]] || continue + + if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then + continue + fi + + if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" + continue + fi + + local owner_repo_root + owner_repo_root="$(dirname "$entry_common_dir")" + local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees" + local target_path + target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + + if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" + continue + fi + + echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" + if [[ "$DRY_RUN" -eq 1 ]]; then + relocated_foreign=$((relocated_foreign + 1)) + continue + fi + + mkdir -p "$owner_worktree_root" + if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then + relocated_foreign=$((relocated_foreign + 1)) + else + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 + fi + done +} + removed_worktrees=0 removed_branches=0 skipped_active=0 skipped_dirty=0 +relocate_foreign_worktree_entries + process_entry() { local wt="$1" local branch_ref="$2" @@ -265,6 +357,9 @@ fi run_cmd git -C "$repo_root" worktree prune 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 [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then + echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}" +fi 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 diff --git a/templates/scripts/agent-worktree-prune.sh b/templates/scripts/agent-worktree-prune.sh index ee82df1..3d51f3a 100644 --- a/templates/scripts/agent-worktree-prune.sh +++ b/templates/scripts/agent-worktree-prune.sh @@ -61,6 +61,11 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_pwd="$(pwd -P)" worktree_root="${repo_root}/.omx/agent-worktrees" +repo_common_dir="$( + git -C "$repo_root" rev-parse --git-common-dir \ + | awk -v root="$repo_root" '{ if ($0 ~ /^\//) { print $0 } else { print root "/" $0 } }' +)" +repo_common_dir="$(cd "$repo_common_dir" && pwd -P)" resolve_base_branch() { local configured="" @@ -132,11 +137,98 @@ is_clean_worktree() { && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] } +resolve_worktree_common_dir() { + local wt="$1" + local common_dir="" + common_dir="$(git -C "$wt" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -z "$common_dir" ]]; then + return 1 + fi + if [[ "$common_dir" == /* ]]; then + common_dir="$(cd "$common_dir" 2>/dev/null && pwd -P || true)" + else + common_dir="$(cd "$wt/$common_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$common_dir" ]]; then + return 1 + fi + printf '%s' "$common_dir" +} + +select_unique_worktree_path() { + local root="$1" + local name="$2" + local candidate="${root}/${name}" + local suffix=2 + while [[ -e "$candidate" ]]; do + candidate="${root}/${name}-${suffix}" + suffix=$((suffix + 1)) + done + printf '%s' "$candidate" +} + +relocated_foreign=0 +skipped_foreign=0 + +relocate_foreign_worktree_entries() { + [[ -d "$worktree_root" ]] || return 0 + + local entry="" + for entry in "${worktree_root}"/*; do + [[ -d "$entry" ]] || continue + if ! git -C "$entry" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + continue + fi + + local entry_common_dir="" + entry_common_dir="$(resolve_worktree_common_dir "$entry" || true)" + [[ -n "$entry_common_dir" ]] || continue + + if [[ "$entry_common_dir" == "$repo_common_dir" ]]; then + continue + fi + + if [[ "$(basename "$entry_common_dir")" != ".git" ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping foreign worktree with unsupported git common dir: ${entry}" + continue + fi + + local owner_repo_root + owner_repo_root="$(dirname "$entry_common_dir")" + local owner_worktree_root="${owner_repo_root}/.omx/agent-worktrees" + local target_path + target_path="$(select_unique_worktree_path "$owner_worktree_root" "$(basename "$entry")")" + + if [[ "$entry" == "$current_pwd" || "$current_pwd" == "${entry}"/* ]]; then + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Skipping active foreign worktree: ${entry}" + continue + fi + + echo "[agent-worktree-prune] Relocating foreign worktree to owning repo: ${entry} -> ${target_path}" + if [[ "$DRY_RUN" -eq 1 ]]; then + relocated_foreign=$((relocated_foreign + 1)) + continue + fi + + mkdir -p "$owner_worktree_root" + if git -C "$owner_repo_root" worktree move "$entry" "$target_path" >/dev/null 2>&1; then + relocated_foreign=$((relocated_foreign + 1)) + else + skipped_foreign=$((skipped_foreign + 1)) + echo "[agent-worktree-prune] Failed to relocate foreign worktree: ${entry}" >&2 + fi + done +} + removed_worktrees=0 removed_branches=0 skipped_active=0 skipped_dirty=0 +relocate_foreign_worktree_entries + process_entry() { local wt="$1" local branch_ref="$2" @@ -265,6 +357,9 @@ fi run_cmd git -C "$repo_root" worktree prune 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 [[ "$relocated_foreign" -gt 0 || "$skipped_foreign" -gt 0 ]]; then + echo "[agent-worktree-prune] Foreign routing: relocated=${relocated_foreign}, skipped=${skipped_foreign}" +fi 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 diff --git a/test/install.test.js b/test/install.test.js index d8e0215..f9a8451 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -2322,6 +2322,41 @@ test('worktree prune --only-dirty-worktrees removes clean agent worktrees but ke assert.equal(branchResult.status, 0, 'unmerged branch ref should remain'); }); +test('worktree prune reroutes foreign worktrees to the owning repo .omx root', () => { + 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 foreignRepoDir = initRepo(); + seedCommit(foreignRepoDir); + + const misplacedPath = path.join(repoDir, '.omx', 'agent-worktrees', 'agent__foreign-owned'); + result = runCmd( + 'git', + ['-C', foreignRepoDir, 'worktree', 'add', '-b', 'agent/foreign-owned', misplacedPath, 'dev'], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(misplacedPath), true, 'foreign worktree should start misplaced under current repo'); + + result = runCmd('bash', ['scripts/agent-worktree-prune.sh'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Relocating foreign worktree to owning repo/); + assert.equal(fs.existsSync(misplacedPath), false, 'misplaced foreign worktree should be moved out'); + + const foreignWorktreeRoot = path.join(foreignRepoDir, '.omx', 'agent-worktrees'); + const relocatedCandidates = fs.existsSync(foreignWorktreeRoot) + ? fs.readdirSync(foreignWorktreeRoot).filter((name) => name.startsWith('agent__foreign-owned')) + : []; + assert.equal(relocatedCandidates.length > 0, true, 'foreign repo should receive relocated worktree'); + + const relocatedPath = path.join(foreignWorktreeRoot, relocatedCandidates[0]); + const commonDirResult = runCmd('git', ['-C', relocatedPath, 'rev-parse', '--git-common-dir'], repoDir); + assert.equal(commonDirResult.status, 0, commonDirResult.stderr || commonDirResult.stdout); + assert.match(commonDirResult.stdout.trim(), new RegExp(`${escapeRegexLiteral(foreignRepoDir)}/\\.git$`)); +}); + test('cleanup command removes merged agent branch/worktree and remote ref', () => { const repoDir = initRepo(); seedCommit(repoDir);