diff --git a/pkg/gui/controllers/helpers/branch_stack.go b/pkg/gui/controllers/helpers/branch_stack.go index 9d7ba4816..b81d1669f 100644 --- a/pkg/gui/controllers/helpers/branch_stack.go +++ b/pkg/gui/controllers/helpers/branch_stack.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/samber/lo" ) @@ -11,6 +12,12 @@ import ( // result is in the order in which the tips appear in commits, so the branch // closest to the given one comes first. // +// A commit counts as merged if the remote branch of a main branch contains it +// (this is what the commit's status says), but also if the local main branch +// contains it. So if a branch was merged into the local main branch, it isn't +// stacked below a branch on top of that main branch, and if the given branch +// is itself a main branch, nothing is stacked below it. +// // Only the commits passed in are looked at, so a branch whose tip is further // down the history than the loaded commits is not found. func BranchesBelowInStack( @@ -20,16 +27,20 @@ func BranchesBelowInStack( mainBranches []string, ) []*models.Branch { branchesByTip := map[string][]*models.Branch{} + mainBranchTips := []string{} for _, other := range branches { - if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) { - continue + if lo.Contains(mainBranches, other.Name) { + mainBranchTips = append(mainBranchTips, other.CommitHash) + } else if other.Name != branch.Name { + branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) } - branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) } + mergedLocally := reachableCommits(commits, mainBranchTips) + result := []*models.Branch{} for _, commit := range commits { - if commit.IsTODO() || commit.Status == models.StatusMerged { + if commit.IsTODO() || commit.Status == models.StatusMerged || mergedLocally.Includes(commit.Hash()) { continue } result = append(result, branchesByTip[commit.Hash()]...) @@ -37,3 +48,30 @@ func BranchesBelowInStack( return result } + +// Returns the hashes of those of the given commits that can be reached from +// any of the given tips by following their parents +func reachableCommits(commits []*models.Commit, tips []string) *set.Set[string] { + commitsByHash := map[string]*models.Commit{} + for _, commit := range commits { + if !commit.IsTODO() { + commitsByHash[commit.Hash()] = commit + } + } + + reachable := set.New[string]() + pending := append([]string{}, tips...) + for len(pending) > 0 { + hash := pending[len(pending)-1] + pending = pending[:len(pending)-1] + + commit, ok := commitsByHash[hash] + if !ok || reachable.Includes(hash) { + continue + } + reachable.Add(hash) + pending = append(pending, commit.Parents()...) + } + + return reachable +} diff --git a/pkg/gui/controllers/helpers/branch_stack_test.go b/pkg/gui/controllers/helpers/branch_stack_test.go index 5fb94d81c..d967acc9e 100644 --- a/pkg/gui/controllers/helpers/branch_stack_test.go +++ b/pkg/gui/controllers/helpers/branch_stack_test.go @@ -12,8 +12,8 @@ import ( func TestBranchesBelowInStack(t *testing.T) { hashPool := &utils.StringPool{} - commit := func(hash string, status models.CommitStatus) *models.Commit { - return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status}) + commit := func(hash string, status models.CommitStatus, parents ...string) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status, Parents: parents}) } todoCommit := func(hash string) *models.Commit { return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing}) @@ -29,6 +29,8 @@ func TestBranchesBelowInStack(t *testing.T) { testName string commits []*models.Commit branches []*models.Branch + // The branch to look for branches below; the one called "current" if nil + current *models.Branch expected []string }{ { @@ -69,6 +71,41 @@ func TestBranchesBelowInStack(t *testing.T) { branches: []*models.Branch{current, branch("main", "c1")}, expected: []string{}, }, + { + testName: "nothing is stacked below a main branch", + commits: []*models.Commit{ + commit("m2", models.StatusUnpushed, "m1", "w1"), + commit("w1", models.StatusUnpushed, "m1"), + commit("m1", models.StatusMerged), + }, + branches: []*models.Branch{branch("master", "m2"), branch("wip", "w1")}, + current: branch("master", "m2"), + expected: []string{}, + }, + { + testName: "branches merged into a local main branch below the current one are left out", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed, "c2"), + commit("c2", models.StatusUnpushed, "m2"), + commit("m2", models.StatusUnpushed, "m1", "w1"), + commit("w1", models.StatusUnpushed, "m1"), + commit("m1", models.StatusMerged), + }, + branches: []*models.Branch{current, branch("first", "c2"), branch("master", "m2"), branch("wip", "w1")}, + expected: []string{"first"}, + }, + { + testName: "branches merged into a local main branch that was merged into the current one are left out", + commits: []*models.Commit{ + commit("c3", models.StatusUnpushed, "c2", "m2"), + commit("m2", models.StatusUnpushed, "m1", "w1"), + commit("w1", models.StatusUnpushed, "m1"), + commit("c2", models.StatusUnpushed, "m1"), + commit("m1", models.StatusMerged), + }, + branches: []*models.Branch{current, branch("first", "c2"), branch("master", "m2"), branch("wip", "w1")}, + expected: []string{"first"}, + }, { testName: "branches whose tip is merged are left out", commits: []*models.Commit{ @@ -100,7 +137,11 @@ func TestBranchesBelowInStack(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches) + branch := current + if s.current != nil { + branch = s.current + } + result := BranchesBelowInStack(s.commits, s.branches, branch, mainBranches) names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name }) assert.Equal(t, s.expected, names) })