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