From 790cec267957651843f43deda223a65a59e7c2ac Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Mon, 2 Mar 2026 17:43:38 -0500 Subject: [PATCH] fix(sync): skip branches checked out in other worktrees instead of aborting --- cmd/sync.go | 26 ++++++++------- cmd/sync_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/cmd/sync.go b/cmd/sync.go index fbe637b..1b3d878 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -426,24 +426,20 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient, syncRemo currentWorktreePath = "" } + worktreeSkipSet := make(map[string]string) for _, branch := range sorted { if worktreePath, inWorktree := worktrees[branch.Name]; inWorktree { - // Only error if we're NOT already in this worktree if currentWorktreePath != worktreePath { - return fmt.Errorf( - "cannot sync: branch '%s' is checked out in worktree at %s\n\n"+ - "To sync this stack:\n"+ - " 1. cd %s\n"+ - " 2. stack sync\n\n"+ - "Or remove the worktree: git worktree remove %s", - branch.Name, - worktreePath, - worktreePath, - worktreePath, - ) + worktreeSkipSet[branch.Name] = worktreePath } } } + if len(worktreeSkipSet) > 0 { + for name, path := range worktreeSkipSet { + fmt.Fprintf(os.Stderr, "%s Skipping %s (checked out in worktree at %s)\n", ui.WarningIcon(), ui.Branch(name), path) + } + fmt.Println() + } // Collect all branches we need PR info for (stack branches + their parents) prBranchSet := make(map[string]bool) @@ -489,6 +485,12 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient, syncRemo for i, branch := range sorted { progress := ui.Progress(i+1, len(sorted)) + // Skip branches checked out in other worktrees + if worktreePath, skip := worktreeSkipSet[branch.Name]; skip { + fmt.Printf("%s Skipping %s (checked out in %s)\n\n", progress, ui.Branch(branch.Name), worktreePath) + continue + } + // Check if this branch has a merged PR - if so, remove from stack tracking if pr, exists := prCache[branch.Name]; exists && pr.State == "MERGED" { fmt.Printf("%s Skipping %s (PR #%d is %s)...\n", progress, ui.Branch(branch.Name), pr.Number, ui.PRState(pr.State)) diff --git a/cmd/sync_test.go b/cmd/sync_test.go index daaf971..2eeb696 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -1017,3 +1017,86 @@ func TestDetermineSyncRemote(t *testing.T) { assert.Equal(t, "origin", result) }) } + +func TestRunSyncSkipsWorktreeBranches(t *testing.T) { + testutil.SetupTest() + defer testutil.TeardownTest() + + t.Run("skips branch checked out in another worktree", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + mockGH := new(testutil.MockGitHubClient) + + // Setup: no existing sync state + mockGit.On("GetConfig", "stack.sync.stashed").Return("") + mockGit.On("GetConfig", "stack.sync.originalBranch").Return("") + mockGit.On("GetCurrentBranch").Return("feature-c", nil) + mockGit.On("SetConfig", "stack.sync.originalBranch", "feature-c").Return(nil) + mockGit.On("IsWorkingTreeClean").Return(true, nil) + mockGit.On("GetConfig", "branch.feature-c.stackparent").Return("feature-b") + mockGit.On("GetConfig", "stack.baseBranch").Return("").Maybe() + mockGit.On("GetDefaultBranch").Return("main").Maybe() + + stackParents := map[string]string{ + "feature-a": "main", + "feature-b": "feature-a", + "feature-c": "feature-b", + } + mockGit.On("GetAllStackParents").Return(stackParents, nil).Maybe() + + // Parallel operations + mockGit.On("FetchRemote", "origin").Return(nil) + mockGH.On("GetPRsForBranches", mock.Anything).Return(make(map[string]*github.PRInfo)) + + // feature-b is in another worktree + mockGit.On("GetWorktreeBranches").Return(map[string]string{ + "feature-b": "/other/worktree", + }, nil) + mockGit.On("GetCurrentWorktreePath").Return("/Users/test/repo", nil) + + mockGit.On("GetRemoteBranchesSet").Return(map[string]bool{ + "main": true, + "feature-a": true, + "feature-b": true, + "feature-c": true, + }) + + // Process feature-a (not skipped) + mockGit.On("CheckoutBranch", "feature-a").Return(nil) + mockGit.On("GetCommitHash", "feature-a").Return("aaa111", nil) + mockGit.On("GetCommitHash", "origin/feature-a").Return("aaa111", nil) + mockGit.On("FetchBranchFromRemote", "origin", "main").Return(nil) + mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{"aaa111"}, nil) + mockGit.On("GetMergeBase", "feature-a", "origin/main").Return("main123", nil) + mockGit.On("GetCommitHash", "origin/main").Return("main123", nil) + mockGit.On("Rebase", "origin/main").Return(nil) + mockGit.On("FetchBranch", "feature-a").Return(nil) + mockGit.On("PushWithExpectedRemote", "feature-a", "aaa111").Return(nil) + + // feature-b is SKIPPED (in another worktree) - no checkout/rebase/push calls + + // Process feature-c (not skipped) + mockGit.On("CheckoutBranch", "feature-c").Return(nil) + mockGit.On("GetCommitHash", "feature-c").Return("ccc333", nil) + mockGit.On("GetCommitHash", "origin/feature-c").Return("ccc333", nil) + mockGit.On("GetUniqueCommitsByPatch", "feature-b", "feature-c").Return([]string{"ccc333"}, nil) + mockGit.On("GetMergeBase", "feature-c", "feature-b").Return("bbb222", nil) + mockGit.On("GetCommitHash", "feature-b").Return("bbb222", nil) + mockGit.On("Rebase", "feature-b").Return(nil) + mockGit.On("FetchBranch", "feature-c").Return(nil) + mockGit.On("PushWithExpectedRemote", "feature-c", "ccc333").Return(nil) + + // Return to original branch + mockGit.On("CheckoutBranch", "feature-c").Return(nil) + // Clean up sync state + mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) + mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + + err := runSync(mockGit, mockGH, "origin") + + assert.NoError(t, err) + // Verify feature-b was never checked out + mockGit.AssertNotCalled(t, "CheckoutBranch", "feature-b") + mockGit.AssertExpectations(t) + mockGH.AssertExpectations(t) + }) +}