Don't offer to push or pull branches merged into a local main branch

When pushing master, lazygit offers to push other branches along with
it if they were merged into master locally and have unpushed commits.
Pulling master offers to update such branches in the same way. These
branches aren't part of a stack, so the menu only adds an extra step to
an ordinary push or pull.

This happens because a branch counts as stacked below the current one
if its tip is one of the current branch's commits that aren't merged
into a main branch yet. A commit's merged status only looks at the
remote branch of each main branch. So until master is pushed, the
commits that were merged into it locally count as unmerged. The same
goes for pushing or pulling a feature branch on top of such a master.

Treat the commits that a local main branch contains as merged too, so
that the search for stacked branches stops at a main branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-31 15:12:36 +01:00
co-authored by Claude Opus 5.5
parent c9c0db40bd
commit fb1b6ed1c1
2 changed files with 86 additions and 7 deletions
+42 -4
View File
@@ -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
}
@@ -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)
})