mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-05-22 10:15:43 +02:00
Improve performance of showDivergenceFromBaseBranch (#5536)
### PR Description Instead of manually figuring out how much ahead each branch is from it's upstream branch, use [for-each-ref ahead-behind](https://git-scm.com/docs/git-for-each-ref/2.41.0#Documentation/git-for-each-ref.txt-ahead-behindcommittish) if supported by the git binary. On my Windows machine, using the lazygit repo with all 61 branches created locally, it goes from ~8.5s to ~200ms. It was also able to find a base branch for `assets` which the previous iteration did not. Fixes #5532
This commit is contained in:
@@ -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:<base>) 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:<base1>)\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:
|
||||
//
|
||||
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\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)
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user