From 2b4f0de0a6b929a8cdb80cee585c744127754245 Mon Sep 17 00:00:00 2001 From: Guodong Zhu Date: Sun, 22 Mar 2026 02:42:07 -0400 Subject: [PATCH] fix: update worktree .git pointers after forward repo migration When wt context add migrates a repo (moves it to ~/.wt/repos//base and creates a symlink at the original location), pre-existing worktrees' .git files still point to the symlink path. After wt switch moves the symlink away from base, these worktrees break because the symlink path no longer resolves to a .git directory. Fix: after migration, update all worktree .git files to use the physical base path. Extract _wt_update_worktree_pointers as a shared helper used by both context add (forward) and context remove (reverse). Also call updateWorktreePointers from the plugin's AddContextAction migration path. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/wt-context | 74 +++---- lib/wt-common | 75 +++++++ lib/wt-context-setup | 6 + test/integration/wt-context-remove.bats | 5 + test/unit/wt-context-setup.bats | 184 ++++++++++++++++++ .../wt/actions/context/AddContextAction.kt | 5 + .../com/block/wt/services/ContextService.kt | 31 ++- 7 files changed, 331 insertions(+), 49 deletions(-) diff --git a/bin/wt-context b/bin/wt-context index cfecbff..316dda7 100755 --- a/bin/wt-context +++ b/bin/wt-context @@ -175,32 +175,13 @@ remove_context() { git -C "$repo_root" config --local --remove-section wt 2>/dev/null || true fi - # 2. Remove adoption markers from worktrees - info "Removing adoption markers from worktrees..." - wt_source wt-adopt - local wt_path="" - while IFS= read -r line; do - if [[ "$line" == "worktree "* ]]; then - wt_path="${line#worktree }" - elif [[ -z "$line" && -n "$wt_path" ]]; then - # End of worktree block — check and remove marker - if wt_is_adopted "$wt_path" 2>/dev/null; then - local marker_ctx - marker_ctx="$(wt_read_adopted_context "$wt_path" 2>/dev/null)" || true - # Remove if context matches, or if it's an old empty marker - if [[ -z "$marker_ctx" || "$marker_ctx" == "$context_name" ]]; then - wt_unmark_adopted "$wt_path" 2>/dev/null || true - fi - fi - wt_path="" - fi - done < <(git -C "$repo_root" worktree list --porcelain 2>/dev/null; echo) - - # 3. Delete .conf file + # 2. Delete .conf file info "Removing config file..." rm -f "$conf_file" - # 4. Reverse repo migration: move main repo back to symlink location + # 3. Reverse repo migration: move main repo back to symlink location + # Done BEFORE removing adoption markers so pointer update can check adoption state + wt_source wt-adopt if [[ -L "$active_worktree" ]]; then local link_target link_target="$(readlink "$active_worktree")" @@ -215,28 +196,11 @@ remove_context() { mv "$repo_root" "$active_worktree" # Fix worktree .git pointers after repo move - # Each worktree has a .git FILE containing "gitdir: /.git/worktrees/" - # After moving the repo, these need to point to the new location - if [[ -d "$active_worktree/.git/worktrees" ]]; then - info "Updating worktree references..." - local new_git_dir="$active_worktree/.git" - - for wt_meta_dir in "$new_git_dir"/worktrees/*/; do - [[ -d "$wt_meta_dir" ]] || continue - # Read the gitdir file to find the worktree's .git file location - local gitdir_file="$wt_meta_dir/gitdir" - [[ -f "$gitdir_file" ]] || continue - local wt_dot_git - wt_dot_git="$(cat "$gitdir_file" | tr -d '[:space:]')" - - # Update the .git file in the worktree to point to the new location - if [[ -f "$wt_dot_git" ]]; then - local wt_name - wt_name="$(basename "$wt_meta_dir")" - echo "gitdir: ${new_git_dir}/worktrees/${wt_name}" > "$wt_dot_git" - fi - done - fi + # Only update worktrees that were adopted by this context + _wt_update_worktree_pointers "$active_worktree" "adopted" "$context_name" + + # Update repo_root for subsequent marker removal + repo_root="$active_worktree" else warn "Symlink at $active_worktree points to $link_target (expected $repo_root)" warn "Removing symlink but not moving repository" @@ -246,6 +210,26 @@ remove_context() { warn "$active_worktree exists but is not a symlink — leaving it unchanged" fi + # 4. Remove adoption markers from worktrees (after repo migration so pointers are updated first) + info "Removing adoption markers from worktrees..." + local wt_path="" + while IFS= read -r line; do + if [[ "$line" == "worktree "* ]]; then + wt_path="${line#worktree }" + elif [[ -z "$line" && -n "$wt_path" ]]; then + # End of worktree block — check and remove marker + if wt_is_adopted "$wt_path" 2>/dev/null; then + local marker_ctx + marker_ctx="$(wt_read_adopted_context "$wt_path" 2>/dev/null)" || true + # Remove if context matches, or if it's an old empty marker + if [[ -z "$marker_ctx" || "$marker_ctx" == "$context_name" ]]; then + wt_unmark_adopted "$wt_path" 2>/dev/null || true + fi + fi + wt_path="" + fi + done < <(git -C "$repo_root" worktree list --porcelain 2>/dev/null; echo) + # 5. Update ~/.wt/current local current_context current_context="$(wt_get_current_context)" diff --git a/lib/wt-common b/lib/wt-common index c8d5705..02e3a10 100644 --- a/lib/wt-common +++ b/lib/wt-common @@ -297,6 +297,81 @@ wt_read_config || true # Context name (for display purposes) : "${WT_CONTEXT_NAME:=""}" +# ───────────────────────────────────────────────────────────────────────────── +# Worktree pointer maintenance +# ───────────────────────────────────────────────────────────────────────────── + +# After moving a repo's .git directory to a new location, update +# worktrees' .git files to point to the new .git path. +# +# Each git worktree has a .git FILE (not directory) containing: +# gitdir: /old/path/.git/worktrees/ +# +# After the repo is moved, these pointers become stale. This function +# reads the main repo's .git/worktrees/*/gitdir to find each worktree's +# .git file, then rewrites it to point to the new location. +# +# Args: $1 = repo directory (the new location containing .git/) +# $2 = mode (optional): "unadopted" (default) - only unadopted worktrees +# "adopted" - only worktrees adopted by context +# "all" - all worktrees unconditionally +# $3 = context name (optional, used with "adopted" mode) +_wt_update_worktree_pointers() { + local repo_dir="$1" + local mode="${2:-unadopted}" + local context_name="${3:-}" + local git_dir="$repo_dir/.git" + + [[ -d "$git_dir/worktrees" ]] || return 0 + + info "Updating worktree references..." + for wt_meta_dir in "$git_dir"/worktrees/*/; do + [[ -d "$wt_meta_dir" ]] || continue + + # Read gitdir to find the worktree path + local gitdir_file="$wt_meta_dir/gitdir" + [[ -f "$gitdir_file" ]] || continue + local wt_dot_git + wt_dot_git="$(cat "$gitdir_file" | tr -d '[:space:]')" + [[ -f "$wt_dot_git" ]] || continue + + # Check adoption state based on mode. + # We check the marker directly in the worktree metadata dir rather than + # using wt_is_adopted (which runs git rev-parse) because after a repo + # move the .git pointers are stale and git operations would fail. + # The marker lives at /worktrees//wt/adopted. + local adopted_marker="$wt_meta_dir/wt/adopted" + case "$mode" in + unadopted) + # Skip adopted worktrees — they already have correct physical paths + if [[ -f "$adopted_marker" ]]; then + continue + fi + ;; + adopted) + # Skip unadopted worktrees and worktrees adopted by other contexts + if [[ ! -f "$adopted_marker" ]]; then + continue + fi + if [[ -n "$context_name" ]]; then + local adopted_ctx + adopted_ctx="$(head -1 "$adopted_marker" 2>/dev/null | tr -d '[:space:]')" + if [[ -n "$adopted_ctx" && "$adopted_ctx" != "$context_name" ]]; then + continue + fi + fi + ;; + all) + # Update everything + ;; + esac + + local wt_name="${wt_meta_dir%/}" + wt_name="${wt_name##*/}" + echo "gitdir: ${git_dir}/worktrees/${wt_name}" > "$wt_dot_git" + done +} + # ───────────────────────────────────────────────────────────────────────────── # Expand ~ to $HOME in a path (bash doesn't expand ~ in variable assignments # or interactive read input) diff --git a/lib/wt-context-setup b/lib/wt-context-setup index 8b896bf..9a22ade 100644 --- a/lib/wt-context-setup +++ b/lib/wt-context-setup @@ -372,6 +372,12 @@ _wt_migrate_repo() { echo "Creating symlink $WT_ACTIVE_WORKTREE -> $WT_MAIN_REPO_ROOT ..." ln -s "$WT_MAIN_REPO_ROOT" "$WT_ACTIVE_WORKTREE" + # Update pre-existing worktree .git pointers to use the physical base path. + # Before migration, worktrees pointed to the original location (now the symlink). + # After wt switch, the symlink moves away from base, breaking those pointers. + # Only update unadopted worktrees — adopted ones already have correct physical paths. + _wt_update_worktree_pointers "$WT_MAIN_REPO_ROOT" "unadopted" + echo " ✓ Migration complete!" elif [[ -L "$WT_ACTIVE_WORKTREE" ]]; then diff --git a/test/integration/wt-context-remove.bats b/test/integration/wt-context-remove.bats index 8c2b32a..e89bf00 100644 --- a/test/integration/wt-context-remove.bats +++ b/test/integration/wt-context-remove.bats @@ -205,6 +205,11 @@ teardown() { # Normalize worktree path wt_path="$(cd "$wt_path" && pwd -P)" + # Mark the worktree as adopted by this context — remove only updates + # worktrees adopted by the context being removed + source "$TEST_HOME/.wt/lib/wt-adopt" + WT_CONTEXT_NAME="ptr-test" wt_mark_adopted "$wt_path" + # Verify the worktree's .git file points to the OLD location local dot_git_content dot_git_content="$(cat "$wt_path/.git")" diff --git a/test/unit/wt-context-setup.bats b/test/unit/wt-context-setup.bats index f6979d3..e568125 100644 --- a/test/unit/wt-context-setup.bats +++ b/test/unit/wt-context-setup.bats @@ -8,6 +8,7 @@ setup() { # Source the libraries under test source "$TEST_HOME/.wt/lib/wt-common" + source "$TEST_HOME/.wt/lib/wt-adopt" source "$TEST_HOME/.wt/lib/wt-context" source "$TEST_HOME/.wt/lib/wt-context-setup" } @@ -229,3 +230,186 @@ teardown() { assert_success } +# ============================================================================= +# Tests for _wt_update_worktree_pointers() +# ============================================================================= + +@test "_wt_update_worktree_pointers updates .git files after repo move" { + # Create a repo with a worktree + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/original-repo") + create_branch "$repo" "feat-branch" + local wt_path="$BATS_TEST_TMPDIR/wt-feat" + create_worktree "$repo" "$wt_path" "feat-branch" + wt_path="$(cd "$wt_path" && pwd -P)" + + # Verify initial .git pointer uses old path + local dot_git_content + dot_git_content="$(cat "$wt_path/.git")" + assert_equal "$dot_git_content" "gitdir: ${repo}/.git/worktrees/wt-feat" + + # Simulate migration: move repo to new location + local new_repo="$BATS_TEST_TMPDIR/new-location" + mv "$repo" "$new_repo" + + # Run the pointer update + run _wt_update_worktree_pointers "$new_repo" + assert_success + + # .git file should now point to the new location + dot_git_content="$(cat "$wt_path/.git")" + assert_equal "$dot_git_content" "gitdir: ${new_repo}/.git/worktrees/wt-feat" + + # Verify git operations work + run git -C "$wt_path" status + assert_success +} + +@test "_wt_update_worktree_pointers is no-op without worktrees" { + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/no-wt-repo") + + # Should succeed silently when there are no worktrees + run _wt_update_worktree_pointers "$repo" + assert_success +} + +@test "_wt_update_worktree_pointers handles multiple worktrees" { + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/multi-wt-repo") + + create_branch "$repo" "branch-a" + create_branch "$repo" "branch-b" + local wt_a="$BATS_TEST_TMPDIR/wt-a" + local wt_b="$BATS_TEST_TMPDIR/wt-b" + create_worktree "$repo" "$wt_a" "branch-a" + create_worktree "$repo" "$wt_b" "branch-b" + wt_a="$(cd "$wt_a" && pwd -P)" + wt_b="$(cd "$wt_b" && pwd -P)" + + # Move repo + local new_repo="$BATS_TEST_TMPDIR/moved-multi" + mv "$repo" "$new_repo" + + run _wt_update_worktree_pointers "$new_repo" + assert_success + + # Both worktrees should be updated + local content_a content_b + content_a="$(cat "$wt_a/.git")" + content_b="$(cat "$wt_b/.git")" + assert_equal "$content_a" "gitdir: ${new_repo}/.git/worktrees/wt-a" + assert_equal "$content_b" "gitdir: ${new_repo}/.git/worktrees/wt-b" +} + +@test "_wt_update_worktree_pointers unadopted mode skips adopted worktrees" { + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/adopt-skip-repo") + + create_branch "$repo" "branch-adopted" + create_branch "$repo" "branch-unadopted" + local wt_adopted="$BATS_TEST_TMPDIR/wt-adopted" + local wt_unadopted="$BATS_TEST_TMPDIR/wt-unadopted" + create_worktree "$repo" "$wt_adopted" "branch-adopted" + create_worktree "$repo" "$wt_unadopted" "branch-unadopted" + wt_adopted="$(cd "$wt_adopted" && pwd -P)" + wt_unadopted="$(cd "$wt_unadopted" && pwd -P)" + + # Mark one worktree as adopted + wt_mark_adopted "$wt_adopted" + + # Move repo + local new_repo="$BATS_TEST_TMPDIR/moved-adopt-skip" + mv "$repo" "$new_repo" + + run _wt_update_worktree_pointers "$new_repo" "unadopted" + assert_success + + # Unadopted worktree should be updated + local content_unadopted + content_unadopted="$(cat "$wt_unadopted/.git")" + assert_equal "$content_unadopted" "gitdir: ${new_repo}/.git/worktrees/wt-unadopted" + + # Adopted worktree should NOT be updated (still has old path) + local content_adopted + content_adopted="$(cat "$wt_adopted/.git")" + assert_equal "$content_adopted" "gitdir: ${repo}/.git/worktrees/wt-adopted" +} + +@test "_wt_update_worktree_pointers adopted mode only updates worktrees adopted by context" { + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/adopt-ctx-repo") + + create_branch "$repo" "branch-ctx-a" + create_branch "$repo" "branch-ctx-b" + create_branch "$repo" "branch-unadopted" + local wt_ctx_a="$BATS_TEST_TMPDIR/wt-ctx-a" + local wt_ctx_b="$BATS_TEST_TMPDIR/wt-ctx-b" + local wt_unadopted="$BATS_TEST_TMPDIR/wt-ctx-unadopted" + create_worktree "$repo" "$wt_ctx_a" "branch-ctx-a" + create_worktree "$repo" "$wt_ctx_b" "branch-ctx-b" + create_worktree "$repo" "$wt_unadopted" "branch-unadopted" + wt_ctx_a="$(cd "$wt_ctx_a" && pwd -P)" + wt_ctx_b="$(cd "$wt_ctx_b" && pwd -P)" + wt_unadopted="$(cd "$wt_unadopted" && pwd -P)" + + # Mark worktrees as adopted by different contexts + export WT_CONTEXT_NAME="ctx-a" + wt_mark_adopted "$wt_ctx_a" + export WT_CONTEXT_NAME="ctx-b" + wt_mark_adopted "$wt_ctx_b" + unset WT_CONTEXT_NAME + + # Move repo + local new_repo="$BATS_TEST_TMPDIR/moved-adopt-ctx" + mv "$repo" "$new_repo" + + run _wt_update_worktree_pointers "$new_repo" "adopted" "ctx-a" + assert_success + + # Only the worktree adopted by ctx-a should be updated + local content_a + content_a="$(cat "$wt_ctx_a/.git")" + assert_equal "$content_a" "gitdir: ${new_repo}/.git/worktrees/wt-ctx-a" + + # Worktree adopted by ctx-b should NOT be updated + local content_b + content_b="$(cat "$wt_ctx_b/.git")" + assert_equal "$content_b" "gitdir: ${repo}/.git/worktrees/wt-ctx-b" + + # Unadopted worktree should NOT be updated + local content_unadopted + content_unadopted="$(cat "$wt_unadopted/.git")" + assert_equal "$content_unadopted" "gitdir: ${repo}/.git/worktrees/wt-ctx-unadopted" +} + +@test "_wt_update_worktree_pointers all mode updates everything" { + local repo + repo=$(create_mock_repo "$BATS_TEST_TMPDIR/all-mode-repo") + + create_branch "$repo" "branch-adopted" + create_branch "$repo" "branch-unadopted" + local wt_adopted="$BATS_TEST_TMPDIR/wt-all-adopted" + local wt_unadopted="$BATS_TEST_TMPDIR/wt-all-unadopted" + create_worktree "$repo" "$wt_adopted" "branch-adopted" + create_worktree "$repo" "$wt_unadopted" "branch-unadopted" + wt_adopted="$(cd "$wt_adopted" && pwd -P)" + wt_unadopted="$(cd "$wt_unadopted" && pwd -P)" + + wt_mark_adopted "$wt_adopted" + + # Move repo + local new_repo="$BATS_TEST_TMPDIR/moved-all-mode" + mv "$repo" "$new_repo" + + run _wt_update_worktree_pointers "$new_repo" "all" + assert_success + + # Both should be updated + local content_adopted content_unadopted + content_adopted="$(cat "$wt_adopted/.git")" + content_unadopted="$(cat "$wt_unadopted/.git")" + assert_equal "$content_adopted" "gitdir: ${new_repo}/.git/worktrees/wt-all-adopted" + assert_equal "$content_unadopted" "gitdir: ${new_repo}/.git/worktrees/wt-all-unadopted" +} + diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt index e378134..7ce87db 100644 --- a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/actions/context/AddContextAction.kt @@ -4,6 +4,7 @@ import com.block.wt.actions.WtAction import com.block.wt.model.ContextConfig import com.block.wt.services.ContextService import com.block.wt.services.MetadataService +import com.block.wt.services.PointerUpdateMode import com.block.wt.services.WorktreeService import com.block.wt.ui.AddContextDialog import com.block.wt.ui.Notifications @@ -58,6 +59,10 @@ class AddContextAction : WtAction() { indicator.text = "Creating symlink..." Files.createSymbolicLink(activeWorktree, mainRepoRoot) + + // Update pre-existing worktree .git pointers to use the physical base path + // Only update unadopted worktrees — adopted ones already have correct physical paths + ContextService.getInstance(project).updateWorktreePointers(mainRepoRoot, PointerUpdateMode.UNADOPTED_ONLY) } else if (!Files.exists(activeWorktree)) { // Neither exists — create symlink if mainRepoRoot exists if (Files.isDirectory(mainRepoRoot)) { diff --git a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt index ed1eb8c..7fa0d16 100644 --- a/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt +++ b/wt-jetbrains-plugin/src/main/kotlin/com/block/wt/services/ContextService.kt @@ -3,6 +3,7 @@ package com.block.wt.services import com.block.wt.git.GitConfigHelper import com.block.wt.git.GitDirResolver import com.block.wt.model.ContextConfig +import com.block.wt.provision.ProvisionMarkerService import com.block.wt.util.ConfigFileHelper import com.block.wt.util.PathHelper import com.intellij.openapi.components.Service @@ -16,6 +17,8 @@ import kotlinx.coroutines.flow.asStateFlow import java.nio.file.Files import java.nio.file.Path +enum class PointerUpdateMode { ALL, UNADOPTED_ONLY, ADOPTED_BY_CONTEXT } + @Service(Service.Level.PROJECT) class ContextService( private val project: Project, @@ -50,7 +53,7 @@ class ContextService( Files.deleteIfExists(confFile) // 3. Reverse repo migration if applicable - restoreRepository(config.activeWorktree, config.mainRepoRoot) + restoreRepository(config.activeWorktree, config.mainRepoRoot, config.name) // 4. Update ~/.wt/current val currentName = ConfigFileHelper.readCurrentContext() @@ -86,7 +89,7 @@ class ContextService( reload() } - private fun restoreRepository(activeWorktree: Path, mainRepoRoot: Path) { + private fun restoreRepository(activeWorktree: Path, mainRepoRoot: Path, contextName: String) { if (!PathHelper.isSymlink(activeWorktree)) return val linkTarget = PathHelper.readSymlink(activeWorktree) ?: return @@ -100,14 +103,18 @@ class ContextService( // Symlink points to main repo — reverse the migration Files.delete(activeWorktree) Files.move(mainRepoRoot, activeWorktree) - updateWorktreePointers(activeWorktree) + updateWorktreePointers(activeWorktree, PointerUpdateMode.ADOPTED_BY_CONTEXT, contextName) } else { // Symlink points elsewhere — just remove it Files.delete(activeWorktree) } } - private fun updateWorktreePointers(repoDir: Path) { + internal fun updateWorktreePointers( + repoDir: Path, + mode: PointerUpdateMode = PointerUpdateMode.ALL, + contextName: String? = null, + ) { val gitWorktreesDir = repoDir.resolve(".git/worktrees") if (!Files.isDirectory(gitWorktreesDir)) return @@ -116,6 +123,22 @@ class ContextService( .filter { Files.isDirectory(it) } .forEach { wtMetaDir -> val wtDotGit = resolveWorktreeGitFile(wtMetaDir) ?: return@forEach + + // Derive worktree path from .git file path + val wtPath = wtDotGit.parent // .git file is at /.git + + // Check adoption filter + when (mode) { + PointerUpdateMode.UNADOPTED_ONLY -> { + if (ProvisionMarkerService.isProvisioned(wtPath)) return@forEach + } + PointerUpdateMode.ADOPTED_BY_CONTEXT -> { + val ctx = ProvisionMarkerService.readAdoptedContext(wtPath) + if (ctx == null || (contextName != null && ctx != contextName)) return@forEach + } + PointerUpdateMode.ALL -> { /* update all */ } + } + val wtName = wtMetaDir.fileName.toString() Files.writeString(wtDotGit, "gitdir: ${newGitDir.resolve("worktrees/$wtName")}\n") }