From cdf828bfa3c2431c71110cd7f1d58ed0875c9d04 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 21:43:15 +0200 Subject: [PATCH] Keep VS Code Source Control scoped to the repo that owns each worktree Foreign agent worktrees parked under this repo's .omx path were making VS Code show branches under the wrong repository. The prune flow now detects mismatched git common-dir ownership and moves those worktrees into the owning repo's .omx/agent-worktrees directory before normal cleanup logic runs. Added regression coverage for rerouting behavior. Constraint: Must preserve existing cleanup semantics for merged/unmerged local agent branches Rejected: Deleting foreign worktrees automatically | could destroy active work in another repo Confidence: high Scope-risk: narrow Directive: Keep foreign routing in prune/cleanup path so cross-repo worktree bleed is corrected without a separate command Tested: node --check bin/multiagent-safety.js; bash -n scripts/agent-worktree-prune.sh templates/scripts/agent-worktree-prune.sh; npm test Not-tested: Manual VS Code UI verification after cleanup across multiple concurrently open repos --- scripts/agent-worktree-prune.sh | 95 +++++++++++++++++++++++ templates/scripts/agent-worktree-prune.sh | 95 +++++++++++++++++++++++ test/install.test.js | 35 +++++++++ 3 files changed, 225 insertions(+) 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);