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() +}