From 980a0cc65b8b513418c36bcc49e3796c1acb8012 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 16:45:32 +0200 Subject: [PATCH 1/7] Move the ahead-behind for-each-ref helpers to their own file Building and parsing `git for-each-ref --format=%(ahead-behind:)` is not specific to loading local branches; the commits below use it to sort refs by ancestry, from the remote branch loader too. Give these helpers and their tests a file of their own. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/ahead_behind.go | 96 +++++++ .../git_commands/ahead_behind_test.go | 244 ++++++++++++++++++ pkg/commands/git_commands/branch_loader.go | 88 ------- .../git_commands/branch_loader_test.go | 237 ----------------- 4 files changed, 340 insertions(+), 325 deletions(-) create mode 100644 pkg/commands/git_commands/ahead_behind.go create mode 100644 pkg/commands/git_commands/ahead_behind_test.go diff --git a/pkg/commands/git_commands/ahead_behind.go b/pkg/commands/git_commands/ahead_behind.go new file mode 100644 index 000000000..8d7c92212 --- /dev/null +++ b/pkg/commands/git_commands/ahead_behind.go @@ -0,0 +1,96 @@ +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 +} + +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() +} 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..2d9ed63d8 --- /dev/null +++ b/pkg/commands/git_commands/ahead_behind_test.go @@ -0,0 +1,244 @@ +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}}, + }, + }, + }, + { + 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) + }) + } +} diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564f..761ed9ce8 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -206,94 +206,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, diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index f20ce6186..50e981d47 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -126,243 +126,6 @@ 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"} From 36192a0495171f60daef5217da10c3743f63396e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 16:47:00 +0200 Subject: [PATCH 2/7] Let the caller of buildAheadBehindForEachRefArgs choose the refs The function always asked about every ref under refs/heads. Sorting refs by ancestry needs the ahead-behind values of a handful of named refs, and for remote branches those live under refs/remotes, so take the patterns as an argument. The bases can be commit hashes as well as ref names. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/ahead_behind.go | 16 +++++---- .../git_commands/ahead_behind_test.go | 36 +++++++++++++------ pkg/commands/git_commands/branch_loader.go | 2 +- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/pkg/commands/git_commands/ahead_behind.go b/pkg/commands/git_commands/ahead_behind.go index 8d7c92212..f142bf9f4 100644 --- a/pkg/commands/git_commands/ahead_behind.go +++ b/pkg/commands/git_commands/ahead_behind.go @@ -74,23 +74,25 @@ func selectBehindForBranch(aheadBehinds []aheadBehind) int { }).behind } -// The output format is: +// 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 mainBranchRefs. +// with one ahead-behind field per base, in the same order as bases. // // Requires git >= 2.41 (when %(ahead-behind:...) was added). -func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string { - formatParts := make([]string, 0, 1+len(mainBranchRefs)) +func buildAheadBehindForEachRefArgs(bases []string, refPatterns []string) []string { + formatParts := make([]string, 0, 1+len(bases)) formatParts = append(formatParts, "%(refname)") - for _, ref := range mainBranchRefs { - formatParts = append(formatParts, "%(ahead-behind:"+ref+")") + for _, base := range bases { + formatParts = append(formatParts, "%(ahead-behind:"+base+")") } format := strings.Join(formatParts, "%00") return NewGitCmd("for-each-ref"). Arg("--format=" + format). - Arg("refs/heads"). + Arg(refPatterns...). ToArgv() } diff --git a/pkg/commands/git_commands/ahead_behind_test.go b/pkg/commands/git_commands/ahead_behind_test.go index 2d9ed63d8..e963dc975 100644 --- a/pkg/commands/git_commands/ahead_behind_test.go +++ b/pkg/commands/git_commands/ahead_behind_test.go @@ -197,15 +197,17 @@ func TestSelectBehindForBranch(t *testing.T) { func TestBuildAheadBehindForEachRefArgs(t *testing.T) { type scenario struct { - testName string - mainBranchRefs []string - expected []string + testName string + bases []string + refPatterns []string + expected []string } scenarios := []scenario{ { - testName: "single base", - mainBranchRefs: []string{"refs/heads/master"}, + testName: "single base", + bases: []string{"refs/heads/master"}, + refPatterns: []string{"refs/heads"}, expected: []string{ "git", "for-each-ref", @@ -214,8 +216,9 @@ func TestBuildAheadBehindForEachRefArgs(t *testing.T) { }, }, { - testName: "two bases", - mainBranchRefs: []string{"refs/heads/master", "refs/remotes/origin/develop"}, + testName: "two bases", + bases: []string{"refs/heads/master", "refs/remotes/origin/develop"}, + refPatterns: []string{"refs/heads"}, expected: []string{ "git", "for-each-ref", @@ -224,8 +227,9 @@ func TestBuildAheadBehindForEachRefArgs(t *testing.T) { }, }, { - testName: "four bases", - mainBranchRefs: []string{"refs/heads/a", "refs/heads/b", "refs/heads/c", "refs/heads/d"}, + 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", @@ -233,11 +237,23 @@ func TestBuildAheadBehindForEachRefArgs(t *testing.T) { "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.mainBranchRefs) + 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 761ed9ce8..407ae6c10 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -214,7 +214,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 From 1441359ccc3d2ecb227cfc29cce6f00e7065e2fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 16:48:35 +0200 Subject: [PATCH 3/7] Keep the positions of malformed ahead-behind fields parseAheadBehindForEachRefOutput dropped fields it couldn't parse, which left the remaining ones of that line pointing at the wrong bases. The caller that picks the closest base doesn't care, but sorting refs by ancestry has to know which base a pair of numbers belongs to. Return an entry per base and mark the ones that were malformed, as the function's comment has claimed all along. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/ahead_behind.go | 19 +++++---- .../git_commands/ahead_behind_test.go | 42 ++++++++++--------- 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/pkg/commands/git_commands/ahead_behind.go b/pkg/commands/git_commands/ahead_behind.go index f142bf9f4..de8dcbbce 100644 --- a/pkg/commands/git_commands/ahead_behind.go +++ b/pkg/commands/git_commands/ahead_behind.go @@ -10,6 +10,7 @@ import ( // Holds parsed values from a single %(ahead-behind:) field. type aheadBehind struct { ahead, behind int + valid bool } type branchAheadBehind struct { @@ -23,7 +24,8 @@ type branchAheadBehind struct { // // 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 +// 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 @@ -39,7 +41,7 @@ func parseAheadBehindForEachRefOutput( continue } refName := cols[0] - aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) { + aheadBehinds := lo.Map(cols[1:], func(col string, _ int) aheadBehind { return parseAheadBehindField(col) }) entry := branchAheadBehind{ @@ -51,17 +53,17 @@ func parseAheadBehindForEachRefOutput( return result } -func parseAheadBehindField(s string) (aheadBehind, bool) { +func parseAheadBehindField(s string) aheadBehind { parts := strings.Fields(s) if len(parts) != 2 { - return aheadBehind{}, false + return aheadBehind{} } ahead, err1 := strconv.Atoi(parts[0]) behind, err2 := strconv.Atoi(parts[1]) if err1 != nil || err2 != nil { - return aheadBehind{}, false + return aheadBehind{} } - return aheadBehind{ahead: ahead, behind: behind}, true + return aheadBehind{ahead: ahead, behind: behind, valid: true} } // Picks the "closest" base by smallest ahead value (commits the branch @@ -69,7 +71,10 @@ func parseAheadBehindField(s string) (aheadBehind, bool) { // its behind value. // Ties are broken by index order func selectBehindForBranch(aheadBehinds []aheadBehind) int { - return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool { + 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 } diff --git a/pkg/commands/git_commands/ahead_behind_test.go b/pkg/commands/git_commands/ahead_behind_test.go index e963dc975..2fe040633 100644 --- a/pkg/commands/git_commands/ahead_behind_test.go +++ b/pkg/commands/git_commands/ahead_behind_test.go @@ -22,7 +22,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{{ahead: 2, behind: 5}}, + aheadBehinds: []aheadBehind{{ahead: 2, behind: 5, valid: true}}, }, }, }, @@ -35,15 +35,15 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { { refName: "refs/heads/feat", aheadBehinds: []aheadBehind{ - {ahead: 2, behind: 5}, - {ahead: 10, behind: 1}, + {ahead: 2, behind: 5, valid: true}, + {ahead: 10, behind: 1, valid: true}, }, }, { refName: "refs/heads/main", aheadBehinds: []aheadBehind{ - {ahead: 0, behind: 0}, - {ahead: 0, behind: 0}, + {ahead: 0, behind: 0, valid: true}, + {ahead: 0, behind: 0, valid: true}, }, }, }, @@ -56,7 +56,8 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { { refName: "refs/heads/feat", aheadBehinds: []aheadBehind{ - {ahead: 2, behind: 5}, + {}, + {ahead: 2, behind: 5, valid: true}, }, }, }, @@ -68,7 +69,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat/foo-bar", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, }, }, @@ -79,7 +80,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, }, }, @@ -92,11 +93,11 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/good", - aheadBehinds: []aheadBehind{{ahead: 1, behind: 2}}, + aheadBehinds: []aheadBehind{{ahead: 1, behind: 2, valid: true}}, }, { refName: "refs/heads/also_good", - aheadBehinds: []aheadBehind{{ahead: 3, behind: 4}}, + aheadBehinds: []aheadBehind{{ahead: 3, behind: 4, valid: true}}, }, }, }, @@ -107,7 +108,7 @@ func TestParseAheadBehindForEachRefOutput(t *testing.T) { expected: []branchAheadBehind{ { refName: "refs/heads/feat", - aheadBehinds: []aheadBehind{}, + aheadBehinds: []aheadBehind{{}}, }, }, }, @@ -137,14 +138,14 @@ func TestSelectBehindForBranch(t *testing.T) { scenarios := []scenario{ { testName: "single base, valid value", - aheadBehinds: []aheadBehind{{ahead: 3, behind: 7}}, + aheadBehinds: []aheadBehind{{ahead: 3, behind: 7, valid: true}}, expected: 7, }, { testName: "multi-base, clear winner by ahead", aheadBehinds: []aheadBehind{ - {ahead: 50, behind: 10}, // master - {ahead: 5, behind: 2}, // develop ← smallest ahead + {ahead: 50, behind: 10, valid: true}, // master + {ahead: 5, behind: 2, valid: true}, // develop ← smallest ahead }, expected: 2, }, @@ -155,29 +156,30 @@ func TestSelectBehindForBranch(t *testing.T) { // 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 + {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}, // first - {ahead: 5, behind: 99}, // second, same ahead + {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}, + {}, + {ahead: 3, behind: 8, valid: true}, }, expected: 8, }, { testName: "all invalid - returns 0", - aheadBehinds: []aheadBehind{}, + aheadBehinds: []aheadBehind{{}, {}}, expected: 0, }, { From effe661e095880400f519f26bfed41a1fb93174f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 16:57:51 +0200 Subject: [PATCH 4/7] Sort local branches with the same committer date in stack order In date order, the branches panel lists a stack of branches alphabetically rather than from the top of the stack down. Rebasing a stack creates all of its commits within the same second, so all of its branch tips end up carrying the same committer date, and git sorts refs with equal committer dates by name. Sort each group of branches that share a committer date by ancestry instead, so that a branch comes before the branches it is based on. Branches that are not descended from one another keep the alphabetical order git gave them. Determining the ancestry takes one more for-each-ref call, and it only runs when there is a group to sort. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch_loader.go | 31 +- .../git_commands/branch_loader_test.go | 3 +- pkg/commands/git_commands/ref_ancestry.go | 223 ++++++++++++ .../git_commands/ref_ancestry_test.go | 331 ++++++++++++++++++ .../sort_local_branches_in_stack_order.go | 52 +++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 633 insertions(+), 8 deletions(-) create mode 100644 pkg/commands/git_commands/ref_ancestry.go create mode 100644 pkg/commands/git_commands/ref_ancestry_test.go create mode 100644 pkg/integration/tests/branch/sort_local_branches_in_stack_order.go diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 407ae6c10..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) @@ -274,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) @@ -283,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 } @@ -297,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) { @@ -340,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] @@ -361,7 +376,7 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch } } - return &models.Branch{ + branch := &models.Branch{ Name: name, Recency: recency, AheadForPull: aheadForPull, @@ -373,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 50e981d47..3c1402a24 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -120,8 +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) + 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/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/test_list.go b/pkg/integration/tests/test_list.go index ef73e79b1..c7584bb35 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -86,6 +86,7 @@ var tests = []*components.IntegrationTest{ branch.ShowDivergenceFromUpstream, branch.ShowDivergenceFromUpstreamNoDivergence, branch.SortLocalBranches, + branch.SortLocalBranchesInStackOrder, branch.SortRemoteBranches, branch.SquashMerge, branch.Suggestions, From 9123a40135406f62727745160a542a3a9b860612 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 16:58:58 +0200 Subject: [PATCH 5/7] Hand the RemoteLoader its GitCommon It needs the git version to decide whether the ahead-behind format is available, and GitCommon already carries the common state and the command builder that the loader used to take separately. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git.go | 2 +- pkg/commands/git_commands/remote_loader.go | 15 +++------------ pkg/commands/git_commands/remote_loader_test.go | 4 +--- 3 files changed, 5 insertions(+), 16 deletions(-) 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/remote_loader.go b/pkg/commands/git_commands/remote_loader.go index 2daeb600d..7c132f25d 100644 --- a/pkg/commands/git_commands/remote_loader.go +++ b/pkg/commands/git_commands/remote_loader.go @@ -8,24 +8,15 @@ 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" ) 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) { 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 From b91585030036ac5310b3efd881fd18cfccdcd70c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 17:00:22 +0200 Subject: [PATCH 6/7] Load remote branches as one list and group them by remote afterwards Sorting the branches of a remote by ancestry works on the whole list, so build the list first and group it by remote once it is in the right order. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/remote_loader.go | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go index 7c132f25d..21d426201 100644 --- a/pkg/commands/git_commands/remote_loader.go +++ b/pkg/commands/git_commands/remote_loader.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type RemoteLoader struct { @@ -103,8 +104,18 @@ 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 + } + 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) { var sortOrder string switch strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) { case "alphabetical": @@ -121,6 +132,7 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models. Arg("refs/remotes"). ToArgv() + remoteBranches := []*models.RemoteBranch{} err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) { line = strings.TrimSpace(line) @@ -135,12 +147,7 @@ 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, @@ -151,5 +158,5 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models. return nil, err } - return remoteBranchesByRemoteName, nil + return remoteBranches, nil } From afb66f78a91743f9bb324042ea4a2550f6c03591 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 19 Sep 2026 17:03:41 +0200 Subject: [PATCH 7/7] Sort remote branches with the same committer date in stack order Force-pushing a stack of branches puts the same commits on the remote, so the remote branches panel shows the same alphabetical order for a stack that the local branches panel did. Sort them by ancestry too, which needs the tip hash and committer date of each remote branch. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/remote_loader.go | 36 +++++++++---- .../sort_remote_branches_in_stack_order.go | 52 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 pkg/integration/tests/branch/sort_remote_branches_in_stack_order.go diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go index 21d426201..2a0d9dd29 100644 --- a/pkg/commands/git_commands/remote_loader.go +++ b/pkg/commands/git_commands/remote_loader.go @@ -116,27 +116,32 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models. // Returns all remote branches, sorted the way the config asks for func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) { - var sortOrder string - switch strings.ToLower(self.UserConfig().Git.RemoteBranchSortOrder) { - case "alphabetical": - sortOrder = "refname" - case "date": + 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 } @@ -152,11 +157,22 @@ func (self *RemoteLoader) getRemoteBranches() ([]*models.RemoteBranch, error) { 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 } + 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/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 c7584bb35..04b01d475 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -88,6 +88,7 @@ var tests = []*components.IntegrationTest{ branch.SortLocalBranches, branch.SortLocalBranchesInStackOrder, branch.SortRemoteBranches, + branch.SortRemoteBranchesInStackOrder, branch.SquashMerge, branch.Suggestions, branch.UnsetUpstream,