mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-24 23:40:03 -05:00
List stacked branches in stack order (#6024)
With stacked branches it is common to rebase the whole stack in one go, and when doing that it is likely that all branch tips get the same committer date (because git's time stamps have a granularity of one second). We sort branches by committer date by default, and git sorts branches with the same committer date alphabetically, which causes stacked branches to be listed out of order. To improve this, detect groups of branches that share the same committer date, and sort them by ancestry, so that a branch comes before the branches it is based on. This takes one extra for-each-ref call (only when such a group exists), and it requires git 2.41 or later.
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// Holds parsed values from a single %(ahead-behind:<base>) 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:<base1>)\x00...' refs/heads
|
||||
//
|
||||
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
|
||||
// Blank lines are ignored.
|
||||
// Individual malformed ahead-behind fields produce {valid: false} entries, 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:
|
||||
//
|
||||
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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:<base>) field.
|
||||
type aheadBehind struct {
|
||||
ahead, behind int
|
||||
}
|
||||
|
||||
type branchAheadBehind struct {
|
||||
refName string
|
||||
aheadBehinds []aheadBehind
|
||||
}
|
||||
|
||||
// Parses output produced by:
|
||||
//
|
||||
// git for-each-ref --format='%(refname)\x00%(ahead-behind:<base1>)\x00...' refs/heads
|
||||
//
|
||||
// Lines whose NUL-split column count doesn't match (1 + numBases) are dropped.
|
||||
// Blank lines are ignored.
|
||||
// Individual malformed ahead-behind fields produce {valid: false} entries
|
||||
func parseAheadBehindForEachRefOutput(
|
||||
output string,
|
||||
numBases int, // number of %(ahead-behind:...) tokens
|
||||
) []branchAheadBehind {
|
||||
if output == "" {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(output, "\n")
|
||||
result := make([]branchAheadBehind, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
cols := strings.Split(line, "\x00")
|
||||
if len(cols) != numBases+1 {
|
||||
continue
|
||||
}
|
||||
refName := cols[0]
|
||||
aheadBehinds := lo.FilterMap(cols[1:], func(col string, _ int) (aheadBehind, bool) {
|
||||
return parseAheadBehindField(col)
|
||||
})
|
||||
entry := branchAheadBehind{
|
||||
refName: refName,
|
||||
aheadBehinds: aheadBehinds,
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAheadBehindField(s string) (aheadBehind, bool) {
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) != 2 {
|
||||
return aheadBehind{}, false
|
||||
}
|
||||
ahead, err1 := strconv.Atoi(parts[0])
|
||||
behind, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return aheadBehind{}, false
|
||||
}
|
||||
return aheadBehind{ahead: ahead, behind: behind}, true
|
||||
}
|
||||
|
||||
// Picks the "closest" base by smallest ahead value (commits the branch
|
||||
// has that the base doesn't = roughly "since fork point") and returns
|
||||
// its behind value.
|
||||
// Ties are broken by index order
|
||||
func selectBehindForBranch(aheadBehinds []aheadBehind) int {
|
||||
return lo.MinBy(aheadBehinds, func(a, b aheadBehind) bool {
|
||||
return a.ahead < b.ahead
|
||||
}).behind
|
||||
}
|
||||
|
||||
// The output format is:
|
||||
//
|
||||
// <refname>\x00<ahead> <behind>\x00<ahead> <behind>...\n
|
||||
//
|
||||
// with one ahead-behind field per base, in the same order as mainBranchRefs.
|
||||
//
|
||||
// Requires git >= 2.41 (when %(ahead-behind:...) was added).
|
||||
func buildAheadBehindForEachRefArgs(mainBranchRefs []string) []string {
|
||||
formatParts := make([]string, 0, 1+len(mainBranchRefs))
|
||||
formatParts = append(formatParts, "%(refname)")
|
||||
for _, ref := range mainBranchRefs {
|
||||
formatParts = append(formatParts, "%(ahead-behind:"+ref+")")
|
||||
}
|
||||
format := strings.Join(formatParts, "%00")
|
||||
|
||||
return NewGitCmd("for-each-ref").
|
||||
Arg("--format=" + format).
|
||||
Arg("refs/heads").
|
||||
ToArgv()
|
||||
}
|
||||
|
||||
func (self *BranchLoader) getBehindBaseBranchValuesFast(
|
||||
branches []*models.Branch,
|
||||
mainBranchRefs []string,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<tip>) 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)
|
||||
}
|
||||
@@ -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:<hash>) 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user