Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,14 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient, syncRemo
} else if _, exists := prCache[branch.Name]; !exists {
// No PR found - check if branch was merged via git history
remoteBase := syncRemote + "/" + baseBranch
if merged, err := gitClient.IsAncestor(branch.Name, remoteBase); err == nil && merged {
merged, err := gitClient.IsAncestor(branch.Name, remoteBase)
// If branch is ancestor of remote base, also check reverse: if both are ancestors
// of each other, they point to the same commit — a new branch with no commits, not merged
sameCommit := false
if err == nil && merged {
sameCommit, _ = gitClient.IsAncestor(remoteBase, branch.Name)
}
if err == nil && merged && !sameCommit {
fmt.Printf("%s Skipping %s (merged into %s, detected via git history)...\n", progress, ui.Branch(branch.Name), ui.Branch(baseBranch))
fmt.Printf(" Removing from stack tracking...\n")
configKey := fmt.Sprintf("branch.%s.stackparent", branch.Name)
Expand Down Expand Up @@ -540,7 +547,12 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient, syncRemo
} else if parentPR == nil && branch.Parent != baseBranch {
// No PR found for parent - check if parent was merged via git history
remoteBase := syncRemote + "/" + baseBranch
if merged, err := gitClient.IsAncestor(branch.Parent, remoteBase); err == nil && merged {
merged, err := gitClient.IsAncestor(branch.Parent, remoteBase)
sameCommit := false
if err == nil && merged {
sameCommit, _ = gitClient.IsAncestor(remoteBase, branch.Parent)
}
if err == nil && merged && !sameCommit {
fmt.Printf(" Parent %s appears merged into %s (detected via git history)\n", ui.Branch(branch.Parent), ui.Branch(baseBranch))
oldParent = branch.Parent
parentMergedViaGit = true
Expand Down
63 changes: 63 additions & 0 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,8 @@ func TestRunSyncGitBasedMergeDetection(t *testing.T) {

// feature-a: no PR, but IsAncestor returns true → merged via git history
mockGit.On("IsAncestor", "feature-a", "origin/main").Return(true, nil)
// Reverse check: origin/main is NOT ancestor of feature-a (branch has diverged, truly merged)
mockGit.On("IsAncestor", "origin/main", "feature-a").Return(false, nil)
// Remove feature-a from stack
mockGit.On("UnsetConfig", "branch.feature-a.stackparent").Return(nil)

Expand All @@ -1244,6 +1246,7 @@ func TestRunSyncGitBasedMergeDetection(t *testing.T) {

// feature-b's parent (feature-a) has no PR, parent merged via git
// IsAncestor("feature-a", "origin/main") already mocked above → true
// IsAncestor("origin/main", "feature-a") already mocked above → false (truly merged)

// Reparent feature-b from feature-a to main
mockGit.On("GetConfig", "branch.feature-a.stackparent").Return("main")
Expand Down Expand Up @@ -1275,6 +1278,66 @@ func TestRunSyncGitBasedMergeDetection(t *testing.T) {
mockGH.AssertExpectations(t)
})

t.Run("branch with no commits is not treated as merged", 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-a", nil)
mockGit.On("SetConfig", "stack.sync.originalBranch", "feature-a").Return(nil)
mockGit.On("IsWorkingTreeClean").Return(true, nil)
mockGit.On("GetConfig", "branch.feature-a.stackparent").Return("main")
mockGit.On("GetConfig", "stack.baseBranch").Return("").Maybe()
mockGit.On("GetDefaultBranch").Return("main").Maybe()

stackParents := map[string]string{
"feature-a": "main",
}
mockGit.On("GetAllStackParents").Return(stackParents, nil).Maybe()

// No PRs found
mockGit.On("FetchRemote", "origin").Return(nil)
mockGH.On("GetPRsForBranches", mock.Anything).Return(make(map[string]*github.PRInfo))

mockGit.On("GetWorktreeBranches").Return(make(map[string]string), nil)
mockGit.On("GetCurrentWorktreePath").Return("/Users/test/repo", nil)
mockGit.On("GetRemoteBranchesSet").Return(map[string]bool{
"main": true,
"feature-a": true,
})

// feature-a has no commits: both IsAncestor directions return true (same commit)
mockGit.On("IsAncestor", "feature-a", "origin/main").Return(true, nil)
mockGit.On("IsAncestor", "origin/main", "feature-a").Return(true, nil)

// Branch should NOT be removed — should proceed to normal processing
mockGit.On("CheckoutBranch", "feature-a").Return(nil)
mockGit.On("GetCommitHash", "feature-a").Return("main123", nil)
mockGit.On("GetCommitHash", "origin/feature-a").Return("main123", nil)
mockGit.On("FetchBranchFromRemote", "origin", "main").Return(nil)
mockGit.On("GetUniqueCommitsByPatch", "origin/main", "feature-a").Return([]string{}, nil)
mockGit.On("Rebase", "origin/main").Return(nil)
mockGit.On("FetchBranch", "feature-a").Return(nil)
mockGit.On("PushWithExpectedRemote", "feature-a", "main123").Return(nil)

// Return to original branch
mockGit.On("CheckoutBranch", "feature-a").Return(nil)
// Clean up sync state
mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil)
mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil)
mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe()

err := runSync(mockGit, mockGH, "origin")

assert.NoError(t, err)
// Verify branch was NOT removed from stack tracking
mockGit.AssertNotCalled(t, "UnsetConfig", "branch.feature-a.stackparent")
mockGit.AssertExpectations(t)
mockGH.AssertExpectations(t)
})

t.Run("branch not merged via git is processed normally", func(t *testing.T) {
mockGit := new(testutil.MockGitClient)
mockGH := new(testutil.MockGitHubClient)
Expand Down
Loading