Add a function to find the branches stacked below a branch

lazygit will offer to push these along with the current branch, so that
a rebased stack can be pushed in one go. The commits panel already marks
the tips of stacked branches, and this uses the same criterion. A branch
is below the given one if its tip is one of the loaded commits of that
branch that isn't merged into a main branch. This needs no git command.
In whole-graph mode the commit list also contains commits of other
branches, but those are marked as merged, so they don't count.

Not used yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-27 08:21:02 +02:00
co-authored by Claude Fable 5.1
parent 6378618544
commit c3b09cebad
2 changed files with 147 additions and 0 deletions
@@ -0,0 +1,39 @@
package helpers
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/samber/lo"
)
// Returns the branches stacked below the given branch: those whose tip is one
// of the given commits of the branch that hasn't been merged into a main
// branch yet. The branch itself and the main branches are left out. The
// result is in the order in which the tips appear in commits, so the branch
// closest to the given one comes first.
//
// 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(
commits []*models.Commit,
branches []*models.Branch,
branch *models.Branch,
mainBranches []string,
) []*models.Branch {
branchesByTip := map[string][]*models.Branch{}
for _, other := range branches {
if other.Name == branch.Name || lo.Contains(mainBranches, other.Name) {
continue
}
branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other)
}
result := []*models.Branch{}
for _, commit := range commits {
if commit.IsTODO() || commit.Status == models.StatusMerged {
continue
}
result = append(result, branchesByTip[commit.Hash()]...)
}
return result
}
@@ -0,0 +1,108 @@
package helpers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
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})
}
todoCommit := func(hash string) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: todo.Pick, Status: models.StatusRebasing})
}
branch := func(name string, tip string) *models.Branch {
return &models.Branch{Name: name, CommitHash: tip}
}
current := branch("current", "c3")
mainBranches := []string{"master", "main"}
scenarios := []struct {
testName string
commits []*models.Commit
branches []*models.Branch
expected []string
}{
{
testName: "stack of branches, closest to the current one first",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c2", models.StatusPushed),
commit("c1", models.StatusUnpushed),
commit("m1", models.StatusMerged),
},
branches: []*models.Branch{current, branch("first", "c1"), branch("second", "c2"), branch("master", "m1")},
expected: []string{"second", "first"},
},
{
testName: "branch pointing at the same commit as the current one",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("m1", models.StatusMerged),
},
branches: []*models.Branch{current, branch("twin", "c3")},
expected: []string{"twin"},
},
{
testName: "several branches pointing at one commit keep their order",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c1", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("one", "c1"), branch("two", "c1")},
expected: []string{"one", "two"},
},
{
testName: "main branches are left out even if their tip isn't merged",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("c1", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("main", "c1")},
expected: []string{},
},
{
testName: "branches whose tip is merged are left out",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
commit("m1", models.StatusMerged),
commit("m0", models.StatusMerged),
},
branches: []*models.Branch{current, branch("old", "m0"), branch("master", "m1")},
expected: []string{},
},
{
testName: "branches whose tip is not among the commits are left out",
commits: []*models.Commit{
commit("c3", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("sibling", "s1")},
expected: []string{},
},
{
testName: "todo commits of a rebase don't count",
commits: []*models.Commit{
todoCommit("t1"),
commit("c3", models.StatusUnpushed),
},
branches: []*models.Branch{current, branch("other", "t1")},
expected: []string{},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
result := BranchesBelowInStack(s.commits, s.branches, current, mainBranches)
names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name })
assert.Equal(t, s.expected, names)
})
}
}