diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index 69cddfa48..53701a04e 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -149,7 +149,7 @@ func NewGitCommandAux(
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
- remoteLoader := git_commands.NewRemoteLoader(cmn, cmd)
+ remoteLoader := git_commands.NewRemoteLoader(gitCommon)
worktreeLoader := git_commands.NewWorktreeLoader(gitCommon)
stashLoader := git_commands.NewStashLoader(cmn, cmd)
tagLoader := git_commands.NewTagLoader(cmn, cmd)
diff --git a/pkg/commands/git_commands/ahead_behind.go b/pkg/commands/git_commands/ahead_behind.go
new file mode 100644
index 000000000..de8dcbbce
--- /dev/null
+++ b/pkg/commands/git_commands/ahead_behind.go
@@ -0,0 +1,103 @@
+package git_commands
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/samber/lo"
+)
+
+// Holds parsed values from a single %(ahead-behind:) field.
+type aheadBehind struct {
+ ahead, behind int
+ valid bool
+}
+
+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, so
+// that the entries of a line stay aligned with the bases.
+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.Map(cols[1:], func(col string, _ int) aheadBehind {
+ return parseAheadBehindField(col)
+ })
+ entry := branchAheadBehind{
+ refName: refName,
+ aheadBehinds: aheadBehinds,
+ }
+ result = append(result, entry)
+ }
+ return result
+}
+
+func parseAheadBehindField(s string) aheadBehind {
+ parts := strings.Fields(s)
+ if len(parts) != 2 {
+ return aheadBehind{}
+ }
+ ahead, err1 := strconv.Atoi(parts[0])
+ behind, err2 := strconv.Atoi(parts[1])
+ if err1 != nil || err2 != nil {
+ return aheadBehind{}
+ }
+ return aheadBehind{ahead: ahead, behind: behind, valid: 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 {
+ validOnes := lo.Filter(aheadBehinds, func(ab aheadBehind, _ int) bool {
+ return ab.valid
+ })
+ return lo.MinBy(validOnes, func(a, b aheadBehind) bool {
+ return a.ahead < b.ahead
+ }).behind
+}
+
+// Builds a for-each-ref command that reports, for each ref matched by one of
+// refPatterns, how far it is ahead and behind each of the bases. A base is a
+// ref name or a commit hash. The output format is:
+//
+// \x00 \x00 ...\n
+//
+// with one ahead-behind field per base, in the same order as bases.
+//
+// Requires git >= 2.41 (when %(ahead-behind:...) was added).
+func buildAheadBehindForEachRefArgs(bases []string, refPatterns []string) []string {
+ formatParts := make([]string, 0, 1+len(bases))
+ formatParts = append(formatParts, "%(refname)")
+ for _, base := range bases {
+ formatParts = append(formatParts, "%(ahead-behind:"+base+")")
+ }
+ format := strings.Join(formatParts, "%00")
+
+ return NewGitCmd("for-each-ref").
+ Arg("--format=" + format).
+ Arg(refPatterns...).
+ ToArgv()
+}
diff --git a/pkg/commands/git_commands/ahead_behind_test.go b/pkg/commands/git_commands/ahead_behind_test.go
new file mode 100644
index 000000000..2fe040633
--- /dev/null
+++ b/pkg/commands/git_commands/ahead_behind_test.go
@@ -0,0 +1,262 @@
+package git_commands
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+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, valid: true}},
+ },
+ },
+ },
+ {
+ 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, valid: true},
+ {ahead: 10, behind: 1, valid: true},
+ },
+ },
+ {
+ refName: "refs/heads/main",
+ aheadBehinds: []aheadBehind{
+ {ahead: 0, behind: 0, valid: true},
+ {ahead: 0, behind: 0, valid: true},
+ },
+ },
+ },
+ },
+ {
+ 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, valid: true},
+ },
+ },
+ },
+ },
+ {
+ 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, valid: true}},
+ },
+ },
+ },
+ {
+ 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, valid: true}},
+ },
+ },
+ },
+ {
+ 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, valid: true}},
+ },
+ {
+ refName: "refs/heads/also_good",
+ aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}},
+ },
+ },
+ },
+ {
+ 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, valid: true}},
+ expected: 7,
+ },
+ {
+ testName: "multi-base, clear winner by ahead",
+ aheadBehinds: []aheadBehind{
+ {ahead: 50, behind: 10, valid: true}, // master
+ {ahead: 5, behind: 2, valid: true}, // 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, valid: true}, // master
+ {ahead: 5, behind: 5, valid: true}, // develop ← smallest ahead
+ },
+ expected: 5,
+ },
+ {
+ testName: "tie on ahead - first base wins (config order)",
+ aheadBehinds: []aheadBehind{
+ {ahead: 5, behind: 10, valid: true}, // first
+ {ahead: 5, behind: 99, valid: true}, // second, same ahead
+ },
+ expected: 10,
+ },
+ {
+ testName: "first base invalid, second valid",
+ aheadBehinds: []aheadBehind{
+ {},
+ {ahead: 3, behind: 8, valid: true},
+ },
+ 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
+ bases []string
+ refPatterns []string
+ expected []string
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "single base",
+ bases: []string{"refs/heads/master"},
+ refPatterns: []string{"refs/heads"},
+ expected: []string{
+ "git",
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:refs/heads/master)",
+ "refs/heads",
+ },
+ },
+ {
+ testName: "two bases",
+ bases: []string{"refs/heads/master", "refs/remotes/origin/develop"},
+ refPatterns: []string{"refs/heads"},
+ 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",
+ bases: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"},
+ refPatterns: []string{"refs/heads"},
+ 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",
+ },
+ },
+ {
+ testName: "commit hashes as bases, individual refs as patterns",
+ bases: []string{"1234567", "89abcde"},
+ refPatterns: []string{"refs/heads/a", "refs/remotes/origin/b"},
+ expected: []string{
+ "git",
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:1234567)%00%(ahead-behind:89abcde)",
+ "refs/heads/a",
+ "refs/remotes/origin/b",
+ },
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ result := buildAheadBehindForEachRefArgs(s.bases, s.refPatterns)
+ assert.Equal(t, s.expected, result)
+ })
+ }
+}
diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go
index b41b0564f..0ae10be37 100644
--- a/pkg/commands/git_commands/branch_loader.go
+++ b/pkg/commands/git_commands/branch_loader.go
@@ -71,9 +71,17 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
- branches := self.obtainBranches()
+ branches, tips := self.obtainBranches()
- if self.UserConfig().Git.LocalBranchSortOrder == "recency" {
+ switch self.UserConfig().Git.LocalBranchSortOrder {
+ case "date":
+ if err := sortRefsWithEqualDatesByAncestry(
+ self.cmd, self.version, branches, (*models.Branch).FullRefName, tips,
+ ); err != nil {
+ self.Log.Errorf("Failed to sort branches by ancestry: %v", err)
+ }
+
+ case "recency":
reflogBranches := self.obtainReflogBranches(reflogCommits)
// loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches
branchesWithRecency := make([]*models.Branch, 0)
@@ -206,94 +214,6 @@ func (self *BranchLoader) getBehindBaseBranchValuesLegacy(
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,
@@ -302,7 +222,7 @@ func (self *BranchLoader) getBehindBaseBranchValuesFast(
t := time.Now()
output, err := self.cmd.New(
- buildAheadBehindForEachRefArgs(mainBranchRefs),
+ buildAheadBehindForEachRefArgs(mainBranchRefs, []string{"refs/heads"}),
).DontLog().RunWithOutput()
if err != nil {
return err
@@ -362,7 +282,9 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai
return split[0], nil
}
-func (self *BranchLoader) obtainBranches() []*models.Branch {
+// Returns the branches, along with the tip of each of them, keyed by full ref
+// name
+func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) {
output, err := self.getRawBranches()
if err != nil {
panic(err)
@@ -371,7 +293,8 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
trimmedOutput := strings.TrimSpace(output)
outputLines := strings.Split(trimmedOutput, "\n")
- return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
+ tips := make(map[string]refTip, len(outputLines))
+ branches := lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) {
if line == "" {
return nil, false
}
@@ -385,8 +308,12 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
}
storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency"
- return obtainBranch(split, storeCommitDateAsRecency), true
+ branch, tip := obtainBranch(split, storeCommitDateAsRecency)
+ tips[branch.FullRefName()] = tip
+ return branch, true
})
+
+ return branches, tips
}
func (self *BranchLoader) getRawBranches() (string, error) {
@@ -428,7 +355,7 @@ var branchFields = []string{
}
// Obtain branch information from parsed line output of getRawBranches()
-func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch {
+func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch, refTip) {
headMarker := split[0]
fullName := split[1]
upstreamName := split[2]
@@ -449,7 +376,7 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch
}
}
- return &models.Branch{
+ branch := &models.Branch{
Name: name,
Recency: recency,
AheadForPull: aheadForPull,
@@ -461,6 +388,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch
Subject: subject,
CommitHash: commitHash,
}
+
+ return branch, refTip{hash: commitHash, committerDate: commitDate}
}
func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) {
diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go
index f20ce6186..3c1402a24 100644
--- a/pkg/commands/git_commands/branch_loader_test.go
+++ b/pkg/commands/git_commands/branch_loader_test.go
@@ -120,245 +120,9 @@ func TestObtainBranch(t *testing.T) {
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
- branch := obtainBranch(s.input, s.storeCommitDateAsRecency)
+ branch, tip := obtainBranch(s.input, s.storeCommitDateAsRecency)
assert.EqualValues(t, s.expectedBranch, branch)
- })
- }
-}
-
-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)
+ assert.Equal(t, refTip{hash: "123", committerDate: timeStamp}, tip)
})
}
}
diff --git a/pkg/commands/git_commands/ref_ancestry.go b/pkg/commands/git_commands/ref_ancestry.go
new file mode 100644
index 000000000..4ac1ba756
--- /dev/null
+++ b/pkg/commands/git_commands/ref_ancestry.go
@@ -0,0 +1,223 @@
+package git_commands
+
+import (
+ "slices"
+
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/samber/lo"
+)
+
+// The tip commit of a ref, as far as sorting refs by ancestry needs it. The
+// date is the raw %(committerdate:unix) field, and is only ever compared for
+// equality.
+type refTip struct {
+ hash string
+ committerDate string
+}
+
+// Determining ancestry costs one %(ahead-behind:) field per tip, and git
+// evaluates each of those for every tip we ask about, so the work grows with
+// the square of the number of tips. Branches whose tips came out of one rebase
+// are nowhere near this many, so leave the refs in the order git returned them
+// once the tips add up to more than this.
+const maxTipsForAncestrySorting = 100
+
+// Sorts each group of refs that share a committer date so that a ref comes
+// before the refs it is descended from. Refs that are not descended from one
+// another keep the order they came in.
+//
+// git sorts refs with equal committer dates by name, and a stack of branches
+// that gets rebased in one go ends up with the same committer date on all of
+// its tips. Without this, such a stack appears in alphabetical order.
+//
+// refs must be sorted by committer date already, and tips must have an entry
+// for each of them.
+func sortRefsWithEqualDatesByAncestry[T any](
+ cmd oscommands.ICmdObjBuilder,
+ version *GitVersion,
+ refs []T,
+ fullRefName func(T) string,
+ tips map[string]refTip,
+) error {
+ // %(ahead-behind:...) was added in git 2.41
+ if !version.IsAtLeast(2, 41, 0) {
+ return nil
+ }
+
+ groups := lo.Filter(groupRefsWithEqualDates(refs, fullRefName, tips),
+ func(group []T, _ int) bool {
+ return pointAtMoreThanOneCommit(group, fullRefName, tips)
+ })
+ if len(groups) == 0 {
+ return nil
+ }
+
+ // Ancestry is a property of the tip commits, so ask git about each of them
+ // once, however many refs point at it. A repository with several remotes
+ // has the same branches under each of them, and they all share a date.
+ tipHashes := []string{}
+ refNames := []string{}
+ seenTips := set.New[string]()
+ for _, ref := range lo.Flatten(groups) {
+ refName := fullRefName(ref)
+ if hash := tips[refName].hash; !seenTips.Includes(hash) {
+ seenTips.Add(hash)
+ tipHashes = append(tipHashes, hash)
+ refNames = append(refNames, refName)
+ }
+ }
+ if len(tipHashes) > maxTipsForAncestrySorting {
+ return nil
+ }
+
+ containedTips, err := loadContainedTips(cmd, tipHashes, refNames)
+ if err != nil {
+ return err
+ }
+
+ for _, group := range groups {
+ sortGroupByAncestry(group, fullRefName, tips, containedTips)
+ }
+
+ return nil
+}
+
+// Refs that all point at the same commit have no order to be put in, so
+// there is nothing to ask git about them
+func pointAtMoreThanOneCommit[T any](
+ refs []T,
+ fullRefName func(T) string,
+ tips map[string]refTip,
+) bool {
+ firstHash := tips[fullRefName(refs[0])].hash
+ return lo.SomeBy(refs, func(ref T) bool {
+ return tips[fullRefName(ref)].hash != firstHash
+ })
+}
+
+// Returns the runs of consecutive refs that share a committer date, for runs of
+// more than one ref. The returned slices share their backing array with refs,
+// so sorting a run sorts that part of refs.
+func groupRefsWithEqualDates[T any](
+ refs []T,
+ fullRefName func(T) string,
+ tips map[string]refTip,
+) [][]T {
+ committerDate := func(ref T) string {
+ return tips[fullRefName(ref)].committerDate
+ }
+
+ groups := [][]T{}
+ start := 0
+ for i := 1; i <= len(refs); i++ {
+ if i < len(refs) && committerDate(refs[i]) == committerDate(refs[start]) {
+ continue
+ }
+ if i-start > 1 {
+ groups = append(groups, refs[start:i])
+ }
+ start = i
+ }
+
+ return groups
+}
+
+// For each of the given tips, which of the tips its history contains. Keyed by
+// tip hash, with an entry for every tip passed in. refNames names, for each
+// tip, a ref that points at it; git reports the values by ref name.
+func loadContainedTips(
+ cmd oscommands.ICmdObjBuilder,
+ tipHashes []string,
+ refNames []string,
+) (map[string]*set.Set[string], error) {
+ output, err := cmd.New(
+ buildAheadBehindForEachRefArgs(tipHashes, refNames),
+ ).DontLog().RunWithOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ containedTips := make(map[string]*set.Set[string], len(tipHashes))
+ for _, hash := range tipHashes {
+ containedTips[hash] = set.New[string]()
+ }
+ tipByRefName := make(map[string]string, len(refNames))
+ for i, refName := range refNames {
+ tipByRefName[refName] = tipHashes[i]
+ }
+
+ for _, entry := range parseAheadBehindForEachRefOutput(output, len(tipHashes)) {
+ hash, ok := tipByRefName[entry.refName]
+ if !ok {
+ continue
+ }
+ contained := containedTips[hash]
+ for i, ab := range entry.aheadBehinds {
+ // The tip's history contains the other tip if it has commits the
+ // other one doesn't have, and the other one has none it doesn't
+ // have.
+ if ab.valid && ab.ahead > 0 && ab.behind == 0 {
+ contained.Add(tipHashes[i])
+ }
+ }
+ }
+
+ return containedTips, nil
+}
+
+func sortGroupByAncestry[T any](
+ group []T,
+ fullRefName func(T) string,
+ tips map[string]refTip,
+ containedTips map[string]*set.Set[string],
+) {
+ hashes := lo.Map(group, func(ref T, _ int) string {
+ return tips[fullRefName(ref)].hash
+ })
+ contained := lo.Map(hashes, func(hash string, _ int) *set.Set[string] {
+ if contained, ok := containedTips[hash]; ok {
+ return contained
+ }
+ return set.New[string]()
+ })
+ isDescendedFrom := func(descendant int, ancestor int) bool {
+ return contained[descendant].Includes(hashes[ancestor])
+ }
+
+ // Indices into group, holding the refs we haven't placed yet
+ remaining := lo.Range(len(group))
+ // A ref can only go in once every ref that is descended from it is in
+ canBePlaced := func(i int) bool {
+ return !lo.SomeBy(remaining, func(j int) bool {
+ return isDescendedFrom(j, i)
+ })
+ }
+
+ sorted := make([]T, 0, len(group))
+ lastPlaced := -1
+ for len(remaining) > 0 {
+ next := -1
+ if lastPlaced != -1 {
+ // Walk from the ref we placed last down to the refs it is based on,
+ // so that the branches of a stack come out as one run even when an
+ // unrelated branch sorts into the middle of them by name
+ next = slices.IndexFunc(remaining, func(i int) bool {
+ return isDescendedFrom(lastPlaced, i) && canBePlaced(i)
+ })
+ }
+ if next == -1 {
+ next = slices.IndexFunc(remaining, canBePlaced)
+ }
+ if next == -1 {
+ // Commits can't descend from each other in a circle; only take the
+ // first ref so that we don't spin if the values ever say otherwise.
+ next = 0
+ }
+ lastPlaced = remaining[next]
+ sorted = append(sorted, group[lastPlaced])
+ remaining = slices.Delete(remaining, next, next+1)
+ }
+
+ copy(group, sorted)
+}
diff --git a/pkg/commands/git_commands/ref_ancestry_test.go b/pkg/commands/git_commands/ref_ancestry_test.go
new file mode 100644
index 000000000..c4fc4c4f1
--- /dev/null
+++ b/pkg/commands/git_commands/ref_ancestry_test.go
@@ -0,0 +1,331 @@
+package git_commands
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/samber/lo"
+ "github.com/stretchr/testify/assert"
+)
+
+// A branch as the tests below describe it: a name, the hash of its tip, and the
+// committer date of its tip
+type testRef struct {
+ name string
+ hash string
+ date string
+}
+
+func buildTestRefs(refs []testRef) ([]*models.Branch, map[string]refTip) {
+ branches := lo.Map(refs, func(ref testRef, _ int) *models.Branch {
+ return &models.Branch{Name: ref.name, CommitHash: ref.hash}
+ })
+ tips := make(map[string]refTip, len(refs))
+ for _, ref := range refs {
+ tips["refs/heads/"+ref.name] = refTip{hash: ref.hash, committerDate: ref.date}
+ }
+ return branches, tips
+}
+
+func branchNames(branches []*models.Branch) []string {
+ return lo.Map(branches, func(branch *models.Branch, _ int) string {
+ return branch.Name
+ })
+}
+
+func TestGroupRefsWithEqualDates(t *testing.T) {
+ scenarios := []struct {
+ testName string
+ refs []testRef
+ expected [][]string
+ }{
+ {
+ testName: "no refs",
+ refs: []testRef{},
+ expected: [][]string{},
+ },
+ {
+ testName: "all dates distinct",
+ refs: []testRef{
+ {name: "a", hash: "1", date: "300"},
+ {name: "b", hash: "2", date: "200"},
+ {name: "c", hash: "3", date: "100"},
+ },
+ expected: [][]string{},
+ },
+ {
+ testName: "all dates equal",
+ refs: []testRef{
+ {name: "a", hash: "1", date: "100"},
+ {name: "b", hash: "2", date: "100"},
+ {name: "c", hash: "3", date: "100"},
+ },
+ expected: [][]string{{"a", "b", "c"}},
+ },
+ {
+ testName: "two groups, with a lone ref between and after them",
+ refs: []testRef{
+ {name: "a", hash: "1", date: "300"},
+ {name: "b", hash: "2", date: "300"},
+ {name: "c", hash: "3", date: "200"},
+ {name: "d", hash: "4", date: "100"},
+ {name: "e", hash: "5", date: "100"},
+ {name: "f", hash: "6", date: "50"},
+ },
+ expected: [][]string{{"a", "b"}, {"d", "e"}},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ branches, tips := buildTestRefs(s.refs)
+ groups := groupRefsWithEqualDates(branches, (*models.Branch).FullRefName, tips)
+ assert.Equal(t, s.expected, lo.Map(groups,
+ func(group []*models.Branch, _ int) []string {
+ return branchNames(group)
+ }))
+ })
+ }
+}
+
+func TestSortRefsWithEqualDatesByAncestry(t *testing.T) {
+ // A stack of three branches, all committed within the same second, as git
+ // returns them: sorted by name. bottom is the base of middle, which is the
+ // base of top.
+ stack := []testRef{
+ {name: "middle", hash: "m", date: "100"},
+ {name: "top", hash: "t", date: "100"},
+ {name: "bottom", hash: "b", date: "100"},
+ }
+
+ // One %(ahead-behind:) field per tip, in the order the tips appear in
+ // the branch list
+ stackOutput := "refs/heads/middle\x000 0\x000 1\x001 0\n" +
+ "refs/heads/top\x001 0\x000 0\x002 0\n" +
+ "refs/heads/bottom\x000 1\x000 2\x000 0\n"
+
+ stackArgs := []string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:b)",
+ "refs/heads/middle", "refs/heads/top", "refs/heads/bottom",
+ }
+
+ scenarios := []struct {
+ testName string
+ refs []testRef
+ gitVersion *GitVersion
+ expectedArgs []string
+ output string
+ outputErr error
+ expectedOrder []string
+ expectedErr string
+ }{
+ {
+ testName: "a stack of branches is sorted from the top down",
+ refs: stack,
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: stackArgs,
+ output: stackOutput,
+ expectedOrder: []string{"top", "middle", "bottom"},
+ },
+ {
+ testName: "git too old for %(ahead-behind:...), so nothing to do",
+ refs: stack,
+ gitVersion: &GitVersion{2, 40, 0, ""},
+ expectedOrder: []string{"middle", "top", "bottom"},
+ },
+ {
+ testName: "no two refs share a date, so nothing to do",
+ refs: []testRef{
+ {name: "middle", hash: "m", date: "300"},
+ {name: "top", hash: "t", date: "200"},
+ {name: "bottom", hash: "b", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedOrder: []string{"middle", "top", "bottom"},
+ },
+ {
+ testName: "the command fails",
+ refs: stack,
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: stackArgs,
+ outputErr: errors.New("fatal: failed to find 'm'"),
+ expectedOrder: []string{"middle", "top", "bottom"},
+ expectedErr: "fatal: failed to find 'm'",
+ },
+ {
+ testName: "a branch unrelated to the stack is placed by name",
+ refs: []testRef{
+ {name: "middle", hash: "m", date: "100"},
+ {name: "other", hash: "o", date: "100"},
+ {name: "top", hash: "t", date: "100"},
+ {name: "bottom", hash: "b", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: []string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:o)%00%(ahead-behind:t)%00%(ahead-behind:b)",
+ "refs/heads/middle", "refs/heads/other", "refs/heads/top", "refs/heads/bottom",
+ },
+ output: "refs/heads/middle\x000 0\x002 1\x000 1\x001 0\n" +
+ "refs/heads/other\x001 2\x000 0\x001 3\x001 1\n" +
+ "refs/heads/top\x001 0\x003 1\x000 0\x002 0\n" +
+ "refs/heads/bottom\x000 1\x001 1\x000 2\x000 0\n",
+ expectedOrder: []string{"other", "top", "middle", "bottom"},
+ },
+ {
+ testName: "a stack stays together when a branch sorts into the middle of it",
+ refs: []testRef{
+ {name: "add-tests", hash: "t", date: "100"},
+ {name: "cleanup", hash: "c", date: "100"},
+ {name: "fix-parser", hash: "p", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: []string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:t)%00%(ahead-behind:c)%00%(ahead-behind:p)",
+ "refs/heads/add-tests", "refs/heads/cleanup", "refs/heads/fix-parser",
+ },
+ // add-tests is based on fix-parser, and cleanup is on a line of its
+ // own, but its name sorts between the two
+ output: "refs/heads/add-tests\x000 0\x002 1\x001 0\n" +
+ "refs/heads/cleanup\x001 2\x000 0\x001 1\n" +
+ "refs/heads/fix-parser\x000 1\x001 1\x000 0\n",
+ expectedOrder: []string{"add-tests", "fix-parser", "cleanup"},
+ },
+ {
+ testName: "each group is sorted on its own",
+ refs: []testRef{
+ {name: "middle", hash: "m", date: "200"},
+ {name: "top", hash: "t", date: "200"},
+ {name: "lone", hash: "l", date: "150"},
+ {name: "base", hash: "a", date: "100"},
+ {name: "derived", hash: "d", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: []string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:a)%00%(ahead-behind:d)",
+ "refs/heads/middle", "refs/heads/top", "refs/heads/base", "refs/heads/derived",
+ },
+ output: "refs/heads/middle\x000 0\x000 1\x002 0\x001 0\n" +
+ "refs/heads/top\x001 0\x000 0\x003 0\x002 0\n" +
+ "refs/heads/base\x000 2\x000 3\x000 0\x000 1\n" +
+ "refs/heads/derived\x000 1\x000 2\x001 0\x000 0\n",
+ expectedOrder: []string{"top", "middle", "lone", "derived", "base"},
+ },
+ {
+ testName: "a group whose branches are all on one commit is left alone",
+ refs: []testRef{
+ {name: "a", hash: "x", date: "100"},
+ {name: "b", hash: "x", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedOrder: []string{"a", "b"},
+ },
+ {
+ testName: "branches on the same commit are asked about once",
+ refs: []testRef{
+ {name: "mirror-1", hash: "s", date: "100"},
+ {name: "mirror-2", hash: "s", date: "100"},
+ {name: "stack-bottom", hash: "u", date: "100"},
+ {name: "stack-top", hash: "t", date: "100"},
+ },
+ gitVersion: &GitVersion{2, 41, 0, ""},
+ expectedArgs: []string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:s)%00%(ahead-behind:u)%00%(ahead-behind:t)",
+ "refs/heads/mirror-1", "refs/heads/stack-bottom", "refs/heads/stack-top",
+ },
+ output: "refs/heads/mirror-1\x000 0\x001 1\x001 2\n" +
+ "refs/heads/stack-bottom\x001 1\x000 0\x000 1\n" +
+ "refs/heads/stack-top\x002 1\x001 0\x000 0\n",
+ expectedOrder: []string{"mirror-1", "mirror-2", "stack-top", "stack-bottom"},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ runner := oscommands.NewFakeRunner(t)
+ if s.expectedArgs != nil {
+ runner.ExpectGitArgs(s.expectedArgs, s.output, s.outputErr)
+ }
+ gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: s.gitVersion})
+
+ branches, tips := buildTestRefs(s.refs)
+ err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
+ branches, (*models.Branch).FullRefName, tips)
+
+ if s.expectedErr == "" {
+ assert.NoError(t, err)
+ } else {
+ assert.ErrorContains(t, err, s.expectedErr)
+ }
+ assert.Equal(t, s.expectedOrder, branchNames(branches))
+ runner.CheckForMissingCalls()
+ })
+ }
+}
+
+func TestSortRefsWithEqualDatesByAncestry_TooManyTips(t *testing.T) {
+ refs := lo.Map(lo.Range(maxTipsForAncestrySorting+1), func(i int, _ int) testRef {
+ return testRef{
+ name: fmt.Sprintf("branch-%03d", i),
+ hash: fmt.Sprintf("hash-%03d", i),
+ date: "100",
+ }
+ })
+
+ // The runner fails the test if the command runs at all
+ runner := oscommands.NewFakeRunner(t)
+ gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
+
+ branches, tips := buildTestRefs(refs)
+ err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
+ branches, (*models.Branch).FullRefName, tips)
+
+ assert.NoError(t, err)
+ assert.Equal(t, lo.Map(refs, func(ref testRef, _ int) string { return ref.name }),
+ branchNames(branches))
+ runner.CheckForMissingCalls()
+}
+
+// A repository with many remotes has the same branches under each of them, so
+// a group can hold far more refs than the commits they point at
+func TestSortRefsWithEqualDatesByAncestry_ManyRefsOnTwoTips(t *testing.T) {
+ refs := lo.Map(lo.Range(4*maxTipsForAncestrySorting), func(i int, _ int) testRef {
+ return testRef{
+ name: fmt.Sprintf("branch-%03d", i),
+ hash: lo.Ternary(i%2 == 0, "bottom", "top"),
+ date: "100",
+ }
+ })
+
+ runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{
+ "for-each-ref",
+ "--format=%(refname)%00%(ahead-behind:bottom)%00%(ahead-behind:top)",
+ "refs/heads/branch-000", "refs/heads/branch-001",
+ },
+ "refs/heads/branch-000\x000 0\x000 1\n"+
+ "refs/heads/branch-001\x001 0\x000 0\n", nil)
+ gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}})
+
+ branches, tips := buildTestRefs(refs)
+ err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version,
+ branches, (*models.Branch).FullRefName, tips)
+
+ assert.NoError(t, err)
+ // the branches on the top commit first, each half still ordered by name
+ expected := append(
+ lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
+ return ref.name, ref.hash == "top"
+ }),
+ lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) {
+ return ref.name, ref.hash == "bottom"
+ })...)
+ assert.Equal(t, expected, branchNames(branches))
+ runner.CheckForMissingCalls()
+}
diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go
index 2daeb600d..2a0d9dd29 100644
--- a/pkg/commands/git_commands/remote_loader.go
+++ b/pkg/commands/git_commands/remote_loader.go
@@ -8,24 +8,16 @@ import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
- "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/samber/lo"
)
type RemoteLoader struct {
- *common.Common
- cmd oscommands.ICmdObjBuilder
+ *GitCommon
}
-func NewRemoteLoader(
- common *common.Common,
- cmd oscommands.ICmdObjBuilder,
-) *RemoteLoader {
- return &RemoteLoader{
- Common: common,
- cmd: cmd,
- }
+func NewRemoteLoader(gitCommon *GitCommon) *RemoteLoader {
+ return &RemoteLoader{GitCommon: gitCommon}
}
func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
@@ -112,28 +104,44 @@ func (self *RemoteLoader) getRemotesFromConfig() []*models.Remote {
}
func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
- remoteBranchesByRemoteName := make(map[string][]*models.RemoteBranch)
+ remoteBranches, err := self.getRemoteBranches()
+ if err != nil {
+ return nil, err
+ }
- var sortOrder string
- switch strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) {
- case "alphabetical":
- sortOrder = "refname"
- case "date":
+ return lo.GroupBy(remoteBranches, func(branch *models.RemoteBranch) string {
+ return branch.RemoteName
+ }), nil
+}
+
+// Returns all remote branches, sorted the way the config asks for
+func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) {
+ sortByDate := strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) == "date"
+ sortOrder := "refname"
+ if sortByDate {
sortOrder = "-committerdate"
- default:
- sortOrder = "refname"
+ }
+
+ // Asking for the tip of a branch makes git read its commit, so only do it
+ // when we are going to sort by ancestry below
+ format := "%(refname)"
+ if sortByDate {
+ format += "%00%(objectname)%00%(committerdate:unix)"
}
cmdArgs := NewGitCmd("for-each-ref").
Arg(fmt.Sprintf("--sort=%s", sortOrder)).
- Arg("--format=%(refname)").
+ Arg(fmt.Sprintf("--format=%s", format)).
Arg("refs/remotes").
ToArgv()
+ remoteBranches := []*models.RemoteBranch{}
+ tips := map[string]refTip{}
err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
- line = strings.TrimSpace(line)
+ fields := strings.Split(strings.TrimSpace(line), "\x00")
+ refName := fields[0]
- split := strings.SplitN(line, "/", 4)
+ split := strings.SplitN(refName, "/", 4)
if len(split) != 4 {
return false, nil
}
@@ -144,21 +152,27 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.
return false, nil
}
- _, ok := remoteBranchesByRemoteName[remoteName]
- if !ok {
- remoteBranchesByRemoteName[remoteName] = []*models.RemoteBranch{}
- }
-
- remoteBranchesByRemoteName[remoteName] = append(remoteBranchesByRemoteName[remoteName],
+ remoteBranches = append(remoteBranches,
&models.RemoteBranch{
Name: name,
RemoteName: remoteName,
})
+ if len(fields) == 3 {
+ tips[refName] = refTip{hash: fields[1], committerDate: fields[2]}
+ }
return false, nil
})
if err != nil {
return nil, err
}
- return remoteBranchesByRemoteName, nil
+ if sortByDate {
+ if err := sortRefsWithEqualDatesByAncestry(
+ self.cmd, self.version, remoteBranches, (*models.RemoteBranch).FullRefName, tips,
+ ); err != nil {
+ self.Log.Errorf("Failed to sort remote branches by ancestry: %v", err)
+ }
+ }
+
+ return remoteBranches, nil
}
diff --git a/pkg/commands/git_commands/remote_loader_test.go b/pkg/commands/git_commands/remote_loader_test.go
index 3d4ed4d12..8089573ea 100644
--- a/pkg/commands/git_commands/remote_loader_test.go
+++ b/pkg/commands/git_commands/remote_loader_test.go
@@ -6,7 +6,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
- "github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
@@ -95,8 +94,7 @@ func TestGetRemotesFromConfig(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.testName, func(t *testing.T) {
loader := &RemoteLoader{
- Common: common.NewDummyCommon(),
- cmd: oscommands.NewDummyCmdObjBuilder(scenario.runner),
+ GitCommon: buildGitCommon(commonDeps{runner: scenario.runner}),
}
// map iteration order is non-deterministic, so compare unordered
diff --git a/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go b/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go
new file mode 100644
index 000000000..913f3e9ed
--- /dev/null
+++ b/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go
@@ -0,0 +1,52 @@
+package branch
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ . "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var SortLocalBranchesInStackOrder = NewIntegrationTest(NewIntegrationTestArgs{
+ Description: "Sort each group of branches that share a committer date so that a branch comes before the ones it is based on",
+ ExtraCmdArgs: []string{},
+ Skip: false,
+ GitVersion: AtLeast("2.41.0"),
+ SetupConfig: func(config *config.AppConfig) {
+ config.GetUserConfig().Git.LocalBranchSortOrder = "date"
+ },
+ SetupRepo: func(shell *Shell) {
+ // Rebasing a stack of branches gives every commit it creates the same
+ // committer date; give each of these two stacks one date of its own for
+ // the same effect.
+ dateOfFirstStack := "2024-01-01 10:00:00"
+ dateOfSecondStack := "2024-01-01 11:00:00"
+
+ shell.
+ EmptyCommitWithDate("base", dateOfFirstStack).
+ NewBranch("one-bottom").
+ EmptyCommitWithDate("one-bottom", dateOfFirstStack).
+ NewBranch("one-middle").
+ EmptyCommitWithDate("one-middle", dateOfFirstStack).
+ NewBranch("one-top").
+ EmptyCommitWithDate("one-top", dateOfFirstStack).
+ NewBranchFrom("unrelated", "master").
+ EmptyCommitWithDate("unrelated", dateOfFirstStack).
+ NewBranchFrom("two-bottom", "master").
+ EmptyCommitWithDate("two-bottom", dateOfSecondStack).
+ NewBranch("two-top").
+ EmptyCommitWithDate("two-top", dateOfSecondStack).
+ Checkout("master")
+ },
+ Run: func(t *TestDriver, keys config.KeybindingConfig) {
+ t.Views().Branches().
+ Focus().
+ Lines(
+ Contains("master").IsSelected(),
+ Contains("two-top"),
+ Contains("two-bottom"),
+ Contains("one-top"),
+ Contains("one-middle"),
+ Contains("one-bottom"),
+ Contains("unrelated"),
+ )
+ },
+})
diff --git a/pkg/integration/tests/branch/sort_remote_branches_in_stack_order.go b/pkg/integration/tests/branch/sort_remote_branches_in_stack_order.go
new file mode 100644
index 000000000..2a652a309
--- /dev/null
+++ b/pkg/integration/tests/branch/sort_remote_branches_in_stack_order.go
@@ -0,0 +1,52 @@
+package branch
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/config"
+ . "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var SortRemoteBranchesInStackOrder = NewIntegrationTest(NewIntegrationTestArgs{
+ Description: "Sort remote branches that share a committer date so that a branch comes before the ones it is based on",
+ ExtraCmdArgs: []string{},
+ Skip: false,
+ GitVersion: AtLeast("2.41.0"),
+ SetupConfig: func(config *config.AppConfig) {
+ config.GetUserConfig().Git.RemoteBranchSortOrder = "date"
+ },
+ SetupRepo: func(shell *Shell) {
+ // Rebasing a stack of branches gives every commit it creates the same
+ // committer date; give these commits one date for the same effect.
+ date := "2024-01-01 10:00:00"
+
+ shell.
+ EmptyCommitWithDate("base", date).
+ NewBranch("branch-c").
+ EmptyCommitWithDate("c", date).
+ NewBranch("branch-a").
+ EmptyCommitWithDate("a", date).
+ NewBranch("branch-b").
+ EmptyCommitWithDate("b", date).
+ NewBranchFrom("unrelated", "master").
+ EmptyCommitWithDate("unrelated", date).
+ Checkout("master").
+ CloneIntoRemote("origin")
+ },
+ Run: func(t *TestDriver, keys config.KeybindingConfig) {
+ t.Views().Remotes().
+ Focus().
+ Lines(
+ Contains("origin").IsSelected(),
+ ).
+ PressEnter()
+
+ t.Views().RemoteBranches().
+ IsFocused().
+ Lines(
+ Contains("branch-b").IsSelected(),
+ Contains("branch-a"),
+ Contains("branch-c"),
+ Contains("unrelated"),
+ Contains("master"),
+ )
+ },
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index ef73e79b1..04b01d475 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -86,7 +86,9 @@ var tests = []*components.IntegrationTest{
branch.ShowDivergenceFromUpstream,
branch.ShowDivergenceFromUpstreamNoDivergence,
branch.SortLocalBranches,
+ branch.SortLocalBranchesInStackOrder,
branch.SortRemoteBranches,
+ branch.SortRemoteBranchesInStackOrder,
branch.SquashMerge,
branch.Suggestions,
branch.UnsetUpstream,