diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go
index c01b204e0..b41b0564f 100644
--- a/pkg/commands/git_commands/branch_loader.go
+++ b/pkg/commands/git_commands/branch_loader.go
@@ -155,6 +155,17 @@ func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
return nil
}
+ if self.version.IsAtLeast(2, 41, 0) {
+ return self.getBehindBaseBranchValuesFast(branches, mainBranchRefs, renderFunc)
+ }
+ return self.getBehindBaseBranchValuesLegacy(branches, mainBranches, renderFunc)
+}
+
+func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
+ branches []*models.Branch,
+ mainBranches *MainBranches,
+ renderFunc func(),
+) error {
t := time.Now()
errg := errgroup.Group{}
@@ -190,11 +201,134 @@ func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
}
err := errg.Wait()
- self.Log.Debugf("time to get behind base branch values for all branches: %s", time.Since(t))
+ self.Log.Debugf("time to get behind base branch values for all branches (legacy): %s", time.Since(t))
renderFunc()
return err
}
+// Holds parsed values from a single %(ahead-behind:) field.
+type aheadBehind struct {
+ ahead, behind int
+}
+
+type branchAheadBehind struct {
+ refName string
+ aheadBehinds []aheadBehind
+}
+
+// Parses output produced by:
+//
+// git for-each-ref --format='%(refname)\x00%(ahead-behind:)\x00...' refs/heads
+//
+// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
+// Blank lines are ignored.
+// Individual malformed ahead-behind fields produce {valid: false} entries
+func parseAheadBehindForEachRefOutput(
+ output string,
+ numBases int, // number of %(ahead-behind:...) tokens
+) []branchAheadBehind {
+ if output == "" {
+ return nil
+ }
+ lines := strings.Split(output, "\n")
+ result := make([]branchAheadBehind, 0, len(lines))
+ for _, line := range lines {
+ cols := strings.Split(line, "\x00")
+ if len(cols) != numBases+1 {
+ continue
+ }
+ refName := cols[0]
+ aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
+ return parseAheadBehindField(col)
+ })
+ entry := branchAheadBehind{
+ refName: refName,
+ aheadBehinds: aheadBehinds,
+ }
+ result = append(result, entry)
+ }
+ return result
+}
+
+func parseAheadBehindField(s string) (aheadBehind, bool) {
+ parts := strings.Fields(s)
+ if len(parts) != 2 {
+ return aheadBehind{}, false
+ }
+ ahead, err1 := strconv.Atoi(parts[0])
+ behind, err2 := strconv.Atoi(parts[1])
+ if err1 != nil || err2 != nil {
+ return aheadBehind{}, false
+ }
+ return aheadBehind{ahead: ahead, behind: behind}, true
+}
+
+// Picks the "closest" base by smallest ahead value (commits the branch
+// has that the base doesn't = roughly "since fork point") and returns
+// its behind value.
+// Ties are broken by index order
+func selectBehindForBranch(aheadBehinds []aheadBehind) int {
+ return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool {
+ return a.ahead < b.ahead
+ }).behind
+}
+
+// The output format is:
+//
+// \x00 \x00 ...\n
+//
+// with one ahead-behind field per base, in the same order as mainBranchRefs.
+//
+// Requires git >= 2.41 (when %(ahead-behind:...) was added).
+func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
+ formatParts := make([]string, 0, 1+len(mainBranchRefs))
+ formatParts = append(formatParts, "%(refname)")
+ for _, ref := range mainBranchRefs {
+ formatParts = append(formatParts, "%(ahead-behind:"+ref+")")
+ }
+ format := strings.Join(formatParts, "%00")
+
+ return NewGitCmd("for-each-ref").
+ Arg("--format=" + format).
+ Arg("refs/heads").
+ ToArgv()
+}
+
+func (self *BranchLoader) getBehindBaseBranchValuesFast(
+ branches []*models.Branch,
+ mainBranchRefs []string,
+ renderFunc func(),
+) error {
+ t := time.Now()
+
+ output, err := self.cmd.New(
+ buildAheadBehindForEachRefArgs(mainBranchRefs),
+ ).DontLog().RunWithOutput()
+ if err != nil {
+ return err
+ }
+
+ parsed := parseAheadBehindForEachRefOutput(output, len(mainBranchRefs))
+ branchByRef := lo.KeyBy(branches, (*models.Branch).FullRefName)
+
+ for _, p := range parsed {
+ if branch, ok := branchByRef[p.refName]; ok {
+ behind := selectBehindForBranch(p.aheadBehinds)
+ branch.BehindBaseBranch.Store(int32(behind))
+ delete(branchByRef, p.refName)
+ }
+ }
+
+ // Branches not in parse are default to 0
+ for _, branch := range branchByRef {
+ branch.BehindBaseBranch.Store(0)
+ }
+
+ self.Log.Debugf("time to get behind base branch values for all branches (fast): %s", time.Since(t))
+ renderFunc()
+ return nil
+}
+
// Find the base branch for the given branch (i.e. the main branch that the
// given branch was forked off of)
//
diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go
index 27a747879..f20ce6186 100644
--- a/pkg/commands/git_commands/branch_loader_test.go
+++ b/pkg/commands/git_commands/branch_loader_test.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/stretchr/testify/assert"
)
@@ -124,3 +125,369 @@ func TestObtainBranch(t *testing.T) {
})
}
}
+
+func TestParseAheadBehindForEachRefOutput(t *testing.T) {
+ type scenario struct {
+ testName string
+ input string
+ numBases int
+ expected []branchAheadBehind
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "single branch single base",
+ input: "refs/heads/feat\x002 5\n",
+ numBases: 1,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat",
+ aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}},
+ },
+ },
+ },
+ {
+ testName: "multiple branches multiple bases",
+ input: "refs/heads/feat\x002 5\x0010 1\n" +
+ "refs/heads/main\x000 0\x000 0\n",
+ numBases: 2,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat",
+ aheadBehinds: []aheadBehind{
+ {ahead: 2, behind: 5},
+ {ahead: 10, behind: 1},
+ },
+ },
+ {
+ refName: "refs/heads/main",
+ aheadBehinds: []aheadBehind{
+ {ahead: 0, behind: 0},
+ {ahead: 0, behind: 0},
+ },
+ },
+ },
+ },
+ {
+ testName: "empty ahead-behind field for unreachable base",
+ input: "refs/heads/feat\x00\x002 5\n",
+ numBases: 2,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat",
+ aheadBehinds: []aheadBehind{
+ {ahead: 2, behind: 5},
+ },
+ },
+ },
+ },
+ {
+ testName: "ref name containing slashes and dashes",
+ input: "refs/heads/feat/foo-bar\x001 2\n",
+ numBases: 1,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat/foo-bar",
+ aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
+ },
+ },
+ },
+ {
+ testName: "trailing newline and blank lines are ignored",
+ input: "refs/heads/feat\x001 2\n\n",
+ numBases: 1,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat",
+ aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
+ },
+ },
+ },
+ {
+ testName: "line with wrong column count is skipped",
+ input: "refs/heads/good\x001 2\n" +
+ "refs/heads/bad\n" +
+ "refs/heads/also_good\x003 4\n",
+ numBases: 1,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/good",
+ aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}},
+ },
+ {
+ refName: "refs/heads/also_good",
+ aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}},
+ },
+ },
+ },
+ {
+ testName: "malformed ahead-behind field becomes invalid but line is kept",
+ input: "refs/heads/feat\x00not_a_number\n",
+ numBases: 1,
+ expected: []branchAheadBehind{
+ {
+ refName: "refs/heads/feat",
+ aheadBehinds: []aheadBehind{},
+ },
+ },
+ },
+ {
+ testName: "empty input",
+ input: "",
+ numBases: 1,
+ expected: nil,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ result := parseAheadBehindForEachRefOutput(s.input, s.numBases)
+ assert.Equal(t, s.expected, result)
+ })
+ }
+}
+
+func TestSelectBehindForBranch(t *testing.T) {
+ type scenario struct {
+ testName string
+ aheadBehinds []aheadBehind
+ expected int
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "single base, valid value",
+ aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}},
+ expected: 7,
+ },
+ {
+ testName: "multi-base, clear winner by ahead",
+ aheadBehinds: []aheadBehind{
+ {ahead: 50, behind: 10}, // master
+ {ahead: 5, behind: 2}, // develop ← smallest ahead
+ },
+ expected: 2,
+ },
+ {
+ testName: "develop forked from master case (ancestor-of-each-other)",
+ // feat-x has 5 commits since fork from develop.
+ // develop is 50 commits ahead of master.
+ // ahead vs master = 5 + 50 = 55; behind vs master = 0
+ // ahead vs develop = 5; behind vs develop = 5
+ aheadBehinds: []aheadBehind{
+ {ahead: 55, behind: 0}, // master
+ {ahead: 5, behind: 5}, // develop ← smallest ahead
+ },
+ expected: 5,
+ },
+ {
+ testName: "tie on ahead - first base wins (config order)",
+ aheadBehinds: []aheadBehind{
+ {ahead: 5, behind: 10}, // first
+ {ahead: 5, behind: 99}, // second, same ahead
+ },
+ expected: 10,
+ },
+ {
+ testName: "first base invalid, second valid",
+ aheadBehinds: []aheadBehind{
+ {ahead: 3, behind: 8},
+ },
+ expected: 8,
+ },
+ {
+ testName: "all invalid - returns 0",
+ aheadBehinds: []aheadBehind{},
+ expected: 0,
+ },
+ {
+ testName: "empty - returns 0",
+ aheadBehinds: nil,
+ expected: 0,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ result := selectBehindForBranch(s.aheadBehinds)
+ assert.Equal(t, s.expected, result)
+ })
+ }
+}
+
+func TestBuildAheadBehindForEachRefArgs(t *testing.T) {
+ type scenario struct {
+ testName string
+ mainBranchRefs []string
+ expected []string
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "single base",
+ mainBranchRefs: []string{"refs/heads/master"},
+ expected: []string{
+ "git",
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:refs/heads/master)",
+ "refs/heads",
+ },
+ },
+ {
+ testName: "two bases",
+ mainBranchRefs: []string{"refs/heads/master", "refs/remotes/origin/develop"},
+ expected: []string{
+ "git",
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)",
+ "refs/heads",
+ },
+ },
+ {
+ testName: "four bases",
+ mainBranchRefs: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
+ expected: []string{
+ "git",
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:refs/heads/a)%00%(ahead-behind:refs/heads/b)%00%(ahead-behind:refs/heads/c)%00%(ahead-behind:refs/heads/d)",
+ "refs/heads",
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ result := buildAheadBehindForEachRefArgs(s.mainBranchRefs)
+ assert.Equal(t, s.expected, result)
+ })
+ }
+}
+
+func TestGetBehindBaseBranchValuesForAllBranches_FastPath(t *testing.T) {
+ mainBranchRefs := []string{"refs/heads/master", "refs/remotes/origin/develop"}
+
+ // Two branches: feat-x has clear divergence from develop; main matches master exactly.
+ branches := []*models.Branch{
+ {Name: "feat-x"},
+ {Name: "main"},
+ }
+
+ expectedFormat := "%(refname)%00%(ahead-behind:refs/heads/master)%00%(ahead-behind:refs/remotes/origin/develop)"
+ output := "refs/heads/feat-x\x0055 0\x005 5\n" + // picks develop (ahead=5 < 55), behind=5
+ "refs/heads/main\x000 0\x000 0\n" // picks master (first, tie), behind=0
+
+ runner := oscommands.NewFakeRunner(t).
+ ExpectGitArgs([]string{"for-each-ref", "--format=" + expectedFormat, "refs/heads"}, output, nil)
+
+ gitCommon := buildGitCommon(commonDeps{
+ runner: runner,
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ })
+
+ loader := &BranchLoader{
+ Common: gitCommon.Common,
+ GitCommon: gitCommon,
+ cmd: gitCommon.cmd,
+ }
+
+ mainBranches := &MainBranches{
+ c: gitCommon.Common,
+ cmd: gitCommon.cmd,
+ existingMainBranches: mainBranchRefs,
+ previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches,
+ }
+
+ rendered := false
+ err := loader.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, func() { rendered = true })
+ assert.NoError(t, err)
+ assert.True(t, rendered, "renderFunc should have been called")
+
+ assert.Equal(t, int32(5), branches[0].BehindBaseBranch.Load(), "feat-x should be behind develop by 5")
+ assert.Equal(t, int32(0), branches[1].BehindBaseBranch.Load(), "main should be behind master by 0")
+
+ runner.CheckForMissingCalls()
+}
+
+// edge case where a failure would leave artifacts from prior load
+func TestGetBehindBaseBranchValuesForAllBranches_FastPath_ClearsStaleValueWhenBranchMissingFromOutput(t *testing.T) {
+ mainBranchRefs := []string{"refs/heads/master"}
+
+ feat := &models.Branch{Name: "feat-x"}
+ feat.BehindBaseBranch.Store(99) // stale value from a prior load
+ ghost := &models.Branch{Name: "ghost"}
+ ghost.BehindBaseBranch.Store(42) // stale value from a prior load
+
+ expectedFormat := "%(refname)%00%(ahead-behind:refs/heads/master)"
+ output := "refs/heads/feat-x\x003 5\n" // ghost is intentionally absent
+
+ runner := oscommands.NewFakeRunner(t).
+ ExpectGitArgs([]string{"for-each-ref", "--format=" + expectedFormat, "refs/heads"}, output, nil)
+
+ gitCommon := buildGitCommon(commonDeps{
+ runner: runner,
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ })
+
+ loader := &BranchLoader{
+ Common: gitCommon.Common,
+ GitCommon: gitCommon,
+ cmd: gitCommon.cmd,
+ }
+
+ mainBranches := &MainBranches{
+ c: gitCommon.Common,
+ cmd: gitCommon.cmd,
+ existingMainBranches: mainBranchRefs,
+ previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches,
+ }
+
+ err := loader.GetBehindBaseBranchValuesForAllBranches(
+ []*models.Branch{feat, ghost}, mainBranches, func() {})
+ assert.NoError(t, err)
+
+ assert.Equal(t, int32(5), feat.BehindBaseBranch.Load(), "feat-x should be updated to fresh value")
+ assert.Equal(t, int32(0), ghost.BehindBaseBranch.Load(), "ghost should be reset to 0 since it has no fresh data")
+
+ runner.CheckForMissingCalls()
+}
+
+func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {
+ mainBranchRefs := []string{"refs/heads/master"}
+
+ branches := []*models.Branch{
+ {Name: "feat-x"},
+ }
+
+ // In legacy path: per-branch GetBaseBranch (merge-base + for-each-ref --contains)
+ // then rev-list --left-right --count.
+ runner := oscommands.NewFakeRunner(t).
+ ExpectGitArgs([]string{"merge-base", "refs/heads/feat-x", "refs/heads/master"}, "abc123\n", nil).
+ ExpectGitArgs([]string{"for-each-ref", "--contains", "abc123", "--format=%(refname)", "refs/heads/master"}, "refs/heads/master\n", nil).
+ ExpectGitArgs([]string{"rev-list", "--left-right", "--count", "refs/heads/feat-x...refs/heads/master"}, "5\t7\n", nil)
+
+ gitCommon := buildGitCommon(commonDeps{
+ runner: runner,
+ gitVersion: &GitVersion{2, 34, 0, ""}, // pre-2.41, forces legacy
+ })
+
+ loader := &BranchLoader{
+ Common: gitCommon.Common,
+ GitCommon: gitCommon,
+ cmd: gitCommon.cmd,
+ }
+
+ mainBranches := &MainBranches{
+ c: gitCommon.Common,
+ cmd: gitCommon.cmd,
+ existingMainBranches: mainBranchRefs,
+ previousMainBranches: gitCommon.Common.UserConfig().Git.MainBranches,
+ }
+
+ rendered := false
+ err := loader.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, func() { rendered = true })
+ assert.NoError(t, err)
+ assert.True(t, rendered)
+ assert.Equal(t, int32(7), branches[0].BehindBaseBranch.Load())
+
+ runner.CheckForMissingCalls()
+}