diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 407ae6c10..0ae10be37 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -71,9 +71,17 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit, onWorker func(func() error), renderFunc func(), ) ([]*models.Branch, error) { - branches := self.obtainBranches() + branches, tips := self.obtainBranches() - if self.UserConfig().Git.LocalBranchSortOrder == "recency" { + switch self.UserConfig().Git.LocalBranchSortOrder { + case "date": + if err := sortRefsWithEqualDatesByAncestry( + self.cmd, self.version, branches, (*models.Branch).FullRefName, tips, + ); err != nil { + self.Log.Errorf("Failed to sort branches by ancestry: %v", err) + } + + case "recency": reflogBranches := self.obtainReflogBranches(reflogCommits) // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches branchesWithRecency := make([]*models.Branch, 0) @@ -274,7 +282,9 @@ func (self *BranchLoader) GetBaseBranch(branch *models.Branch, mainBranches *Mai return split[0], nil } -func (self *BranchLoader) obtainBranches() []*models.Branch { +// Returns the branches, along with the tip of each of them, keyed by full ref +// name +func (self *BranchLoader) obtainBranches() ([]*models.Branch, map[string]refTip) { output, err := self.getRawBranches() if err != nil { panic(err) @@ -283,7 +293,8 @@ func (self *BranchLoader) obtainBranches() []*models.Branch { trimmedOutput := strings.TrimSpace(output) outputLines := strings.Split(trimmedOutput, "\n") - return lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) { + tips := make(map[string]refTip, len(outputLines)) + branches := lo.FilterMap(outputLines, func(line string, _ int) (*models.Branch, bool) { if line == "" { return nil, false } @@ -297,8 +308,12 @@ func (self *BranchLoader) obtainBranches() []*models.Branch { } storeCommitDateAsRecency := self.UserConfig().Git.LocalBranchSortOrder != "recency" - return obtainBranch(split, storeCommitDateAsRecency), true + branch, tip := obtainBranch(split, storeCommitDateAsRecency) + tips[branch.FullRefName()] = tip + return branch, true }) + + return branches, tips } func (self *BranchLoader) getRawBranches() (string, error) { @@ -340,7 +355,7 @@ var branchFields = []string{ } // Obtain branch information from parsed line output of getRawBranches() -func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch { +func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch, refTip) { headMarker := split[0] fullName := split[1] upstreamName := split[2] @@ -361,7 +376,7 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch } } - return &models.Branch{ + branch := &models.Branch{ Name: name, Recency: recency, AheadForPull: aheadForPull, @@ -373,6 +388,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch Subject: subject, CommitHash: commitHash, } + + return branch, refTip{hash: commitHash, committerDate: commitDate} } func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) { diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index 50e981d47..3c1402a24 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -120,8 +120,9 @@ func TestObtainBranch(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - branch := obtainBranch(s.input, s.storeCommitDateAsRecency) + branch, tip := obtainBranch(s.input, s.storeCommitDateAsRecency) assert.EqualValues(t, s.expectedBranch, branch) + assert.Equal(t, refTip{hash: "123", committerDate: timeStamp}, tip) }) } } diff --git a/pkg/commands/git_commands/ref_ancestry.go b/pkg/commands/git_commands/ref_ancestry.go new file mode 100644 index 000000000..4ac1ba756 --- /dev/null +++ b/pkg/commands/git_commands/ref_ancestry.go @@ -0,0 +1,223 @@ +package git_commands + +import ( + "slices" + + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" +) + +// The tip commit of a ref, as far as sorting refs by ancestry needs it. The +// date is the raw %(committerdate:unix) field, and is only ever compared for +// equality. +type refTip struct { + hash string + committerDate string +} + +// Determining ancestry costs one %(ahead-behind:) field per tip, and git +// evaluates each of those for every tip we ask about, so the work grows with +// the square of the number of tips. Branches whose tips came out of one rebase +// are nowhere near this many, so leave the refs in the order git returned them +// once the tips add up to more than this. +const maxTipsForAncestrySorting = 100 + +// Sorts each group of refs that share a committer date so that a ref comes +// before the refs it is descended from. Refs that are not descended from one +// another keep the order they came in. +// +// git sorts refs with equal committer dates by name, and a stack of branches +// that gets rebased in one go ends up with the same committer date on all of +// its tips. Without this, such a stack appears in alphabetical order. +// +// refs must be sorted by committer date already, and tips must have an entry +// for each of them. +func sortRefsWithEqualDatesByAncestry[T any]( + cmd oscommands.ICmdObjBuilder, + version *GitVersion, + refs []T, + fullRefName func(T) string, + tips map[string]refTip, +) error { + // %(ahead-behind:...) was added in git 2.41 + if !version.IsAtLeast(2, 41, 0) { + return nil + } + + groups := lo.Filter(groupRefsWithEqualDates(refs, fullRefName, tips), + func(group []T, _ int) bool { + return pointAtMoreThanOneCommit(group, fullRefName, tips) + }) + if len(groups) == 0 { + return nil + } + + // Ancestry is a property of the tip commits, so ask git about each of them + // once, however many refs point at it. A repository with several remotes + // has the same branches under each of them, and they all share a date. + tipHashes := []string{} + refNames := []string{} + seenTips := set.New[string]() + for _, ref := range lo.Flatten(groups) { + refName := fullRefName(ref) + if hash := tips[refName].hash; !seenTips.Includes(hash) { + seenTips.Add(hash) + tipHashes = append(tipHashes, hash) + refNames = append(refNames, refName) + } + } + if len(tipHashes) > maxTipsForAncestrySorting { + return nil + } + + containedTips, err := loadContainedTips(cmd, tipHashes, refNames) + if err != nil { + return err + } + + for _, group := range groups { + sortGroupByAncestry(group, fullRefName, tips, containedTips) + } + + return nil +} + +// Refs that all point at the same commit have no order to be put in, so +// there is nothing to ask git about them +func pointAtMoreThanOneCommit[T any]( + refs []T, + fullRefName func(T) string, + tips map[string]refTip, +) bool { + firstHash := tips[fullRefName(refs[0])].hash + return lo.SomeBy(refs, func(ref T) bool { + return tips[fullRefName(ref)].hash != firstHash + }) +} + +// Returns the runs of consecutive refs that share a committer date, for runs of +// more than one ref. The returned slices share their backing array with refs, +// so sorting a run sorts that part of refs. +func groupRefsWithEqualDates[T any]( + refs []T, + fullRefName func(T) string, + tips map[string]refTip, +) [][]T { + committerDate := func(ref T) string { + return tips[fullRefName(ref)].committerDate + } + + groups := [][]T{} + start := 0 + for i := 1; i <= len(refs); i++ { + if i < len(refs) && committerDate(refs[i]) == committerDate(refs[start]) { + continue + } + if i-start > 1 { + groups = append(groups, refs[start:i]) + } + start = i + } + + return groups +} + +// For each of the given tips, which of the tips its history contains. Keyed by +// tip hash, with an entry for every tip passed in. refNames names, for each +// tip, a ref that points at it; git reports the values by ref name. +func loadContainedTips( + cmd oscommands.ICmdObjBuilder, + tipHashes []string, + refNames []string, +) (map[string]*set.Set[string], error) { + output, err := cmd.New( + buildAheadBehindForEachRefArgs(tipHashes, refNames), + ).DontLog().RunWithOutput() + if err != nil { + return nil, err + } + + containedTips := make(map[string]*set.Set[string], len(tipHashes)) + for _, hash := range tipHashes { + containedTips[hash] = set.New[string]() + } + tipByRefName := make(map[string]string, len(refNames)) + for i, refName := range refNames { + tipByRefName[refName] = tipHashes[i] + } + + for _, entry := range parseAheadBehindForEachRefOutput(output, len(tipHashes)) { + hash, ok := tipByRefName[entry.refName] + if !ok { + continue + } + contained := containedTips[hash] + for i, ab := range entry.aheadBehinds { + // The tip's history contains the other tip if it has commits the + // other one doesn't have, and the other one has none it doesn't + // have. + if ab.valid && ab.ahead > 0 && ab.behind == 0 { + contained.Add(tipHashes[i]) + } + } + } + + return containedTips, nil +} + +func sortGroupByAncestry[T any]( + group []T, + fullRefName func(T) string, + tips map[string]refTip, + containedTips map[string]*set.Set[string], +) { + hashes := lo.Map(group, func(ref T, _ int) string { + return tips[fullRefName(ref)].hash + }) + contained := lo.Map(hashes, func(hash string, _ int) *set.Set[string] { + if contained, ok := containedTips[hash]; ok { + return contained + } + return set.New[string]() + }) + isDescendedFrom := func(descendant int, ancestor int) bool { + return contained[descendant].Includes(hashes[ancestor]) + } + + // Indices into group, holding the refs we haven't placed yet + remaining := lo.Range(len(group)) + // A ref can only go in once every ref that is descended from it is in + canBePlaced := func(i int) bool { + return !lo.SomeBy(remaining, func(j int) bool { + return isDescendedFrom(j, i) + }) + } + + sorted := make([]T, 0, len(group)) + lastPlaced := -1 + for len(remaining) > 0 { + next := -1 + if lastPlaced != -1 { + // Walk from the ref we placed last down to the refs it is based on, + // so that the branches of a stack come out as one run even when an + // unrelated branch sorts into the middle of them by name + next = slices.IndexFunc(remaining, func(i int) bool { + return isDescendedFrom(lastPlaced, i) && canBePlaced(i) + }) + } + if next == -1 { + next = slices.IndexFunc(remaining, canBePlaced) + } + if next == -1 { + // Commits can't descend from each other in a circle; only take the + // first ref so that we don't spin if the values ever say otherwise. + next = 0 + } + lastPlaced = remaining[next] + sorted = append(sorted, group[lastPlaced]) + remaining = slices.Delete(remaining, next, next+1) + } + + copy(group, sorted) +} diff --git a/pkg/commands/git_commands/ref_ancestry_test.go b/pkg/commands/git_commands/ref_ancestry_test.go new file mode 100644 index 000000000..c4fc4c4f1 --- /dev/null +++ b/pkg/commands/git_commands/ref_ancestry_test.go @@ -0,0 +1,331 @@ +package git_commands + +import ( + "errors" + "fmt" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// A branch as the tests below describe it: a name, the hash of its tip, and the +// committer date of its tip +type testRef struct { + name string + hash string + date string +} + +func buildTestRefs(refs []testRef) ([]*models.Branch, map[string]refTip) { + branches := lo.Map(refs, func(ref testRef, _ int) *models.Branch { + return &models.Branch{Name: ref.name, CommitHash: ref.hash} + }) + tips := make(map[string]refTip, len(refs)) + for _, ref := range refs { + tips["refs/heads/"+ref.name] = refTip{hash: ref.hash, committerDate: ref.date} + } + return branches, tips +} + +func branchNames(branches []*models.Branch) []string { + return lo.Map(branches, func(branch *models.Branch, _ int) string { + return branch.Name + }) +} + +func TestGroupRefsWithEqualDates(t *testing.T) { + scenarios := []struct { + testName string + refs []testRef + expected [][]string + }{ + { + testName: "no refs", + refs: []testRef{}, + expected: [][]string{}, + }, + { + testName: "all dates distinct", + refs: []testRef{ + {name: "a", hash: "1", date: "300"}, + {name: "b", hash: "2", date: "200"}, + {name: "c", hash: "3", date: "100"}, + }, + expected: [][]string{}, + }, + { + testName: "all dates equal", + refs: []testRef{ + {name: "a", hash: "1", date: "100"}, + {name: "b", hash: "2", date: "100"}, + {name: "c", hash: "3", date: "100"}, + }, + expected: [][]string{{"a", "b", "c"}}, + }, + { + testName: "two groups, with a lone ref between and after them", + refs: []testRef{ + {name: "a", hash: "1", date: "300"}, + {name: "b", hash: "2", date: "300"}, + {name: "c", hash: "3", date: "200"}, + {name: "d", hash: "4", date: "100"}, + {name: "e", hash: "5", date: "100"}, + {name: "f", hash: "6", date: "50"}, + }, + expected: [][]string{{"a", "b"}, {"d", "e"}}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + branches, tips := buildTestRefs(s.refs) + groups := groupRefsWithEqualDates(branches, (*models.Branch).FullRefName, tips) + assert.Equal(t, s.expected, lo.Map(groups, + func(group []*models.Branch, _ int) []string { + return branchNames(group) + })) + }) + } +} + +func TestSortRefsWithEqualDatesByAncestry(t *testing.T) { + // A stack of three branches, all committed within the same second, as git + // returns them: sorted by name. bottom is the base of middle, which is the + // base of top. + stack := []testRef{ + {name: "middle", hash: "m", date: "100"}, + {name: "top", hash: "t", date: "100"}, + {name: "bottom", hash: "b", date: "100"}, + } + + // One %(ahead-behind:) field per tip, in the order the tips appear in + // the branch list + stackOutput := "refs/heads/middle\x000 0\x000 1\x001 0\n" + + "refs/heads/top\x001 0\x000 0\x002 0\n" + + "refs/heads/bottom\x000 1\x000 2\x000 0\n" + + stackArgs := []string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:b)", + "refs/heads/middle", "refs/heads/top", "refs/heads/bottom", + } + + scenarios := []struct { + testName string + refs []testRef + gitVersion *GitVersion + expectedArgs []string + output string + outputErr error + expectedOrder []string + expectedErr string + }{ + { + testName: "a stack of branches is sorted from the top down", + refs: stack, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: stackArgs, + output: stackOutput, + expectedOrder: []string{"top", "middle", "bottom"}, + }, + { + testName: "git too old for %(ahead-behind:...), so nothing to do", + refs: stack, + gitVersion: &GitVersion{2, 40, 0, ""}, + expectedOrder: []string{"middle", "top", "bottom"}, + }, + { + testName: "no two refs share a date, so nothing to do", + refs: []testRef{ + {name: "middle", hash: "m", date: "300"}, + {name: "top", hash: "t", date: "200"}, + {name: "bottom", hash: "b", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedOrder: []string{"middle", "top", "bottom"}, + }, + { + testName: "the command fails", + refs: stack, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: stackArgs, + outputErr: errors.New("fatal: failed to find 'm'"), + expectedOrder: []string{"middle", "top", "bottom"}, + expectedErr: "fatal: failed to find 'm'", + }, + { + testName: "a branch unrelated to the stack is placed by name", + refs: []testRef{ + {name: "middle", hash: "m", date: "100"}, + {name: "other", hash: "o", date: "100"}, + {name: "top", hash: "t", date: "100"}, + {name: "bottom", hash: "b", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: []string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:o)%00%(ahead-behind:t)%00%(ahead-behind:b)", + "refs/heads/middle", "refs/heads/other", "refs/heads/top", "refs/heads/bottom", + }, + output: "refs/heads/middle\x000 0\x002 1\x000 1\x001 0\n" + + "refs/heads/other\x001 2\x000 0\x001 3\x001 1\n" + + "refs/heads/top\x001 0\x003 1\x000 0\x002 0\n" + + "refs/heads/bottom\x000 1\x001 1\x000 2\x000 0\n", + expectedOrder: []string{"other", "top", "middle", "bottom"}, + }, + { + testName: "a stack stays together when a branch sorts into the middle of it", + refs: []testRef{ + {name: "add-tests", hash: "t", date: "100"}, + {name: "cleanup", hash: "c", date: "100"}, + {name: "fix-parser", hash: "p", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: []string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:t)%00%(ahead-behind:c)%00%(ahead-behind:p)", + "refs/heads/add-tests", "refs/heads/cleanup", "refs/heads/fix-parser", + }, + // add-tests is based on fix-parser, and cleanup is on a line of its + // own, but its name sorts between the two + output: "refs/heads/add-tests\x000 0\x002 1\x001 0\n" + + "refs/heads/cleanup\x001 2\x000 0\x001 1\n" + + "refs/heads/fix-parser\x000 1\x001 1\x000 0\n", + expectedOrder: []string{"add-tests", "fix-parser", "cleanup"}, + }, + { + testName: "each group is sorted on its own", + refs: []testRef{ + {name: "middle", hash: "m", date: "200"}, + {name: "top", hash: "t", date: "200"}, + {name: "lone", hash: "l", date: "150"}, + {name: "base", hash: "a", date: "100"}, + {name: "derived", hash: "d", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: []string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:m)%00%(ahead-behind:t)%00%(ahead-behind:a)%00%(ahead-behind:d)", + "refs/heads/middle", "refs/heads/top", "refs/heads/base", "refs/heads/derived", + }, + output: "refs/heads/middle\x000 0\x000 1\x002 0\x001 0\n" + + "refs/heads/top\x001 0\x000 0\x003 0\x002 0\n" + + "refs/heads/base\x000 2\x000 3\x000 0\x000 1\n" + + "refs/heads/derived\x000 1\x000 2\x001 0\x000 0\n", + expectedOrder: []string{"top", "middle", "lone", "derived", "base"}, + }, + { + testName: "a group whose branches are all on one commit is left alone", + refs: []testRef{ + {name: "a", hash: "x", date: "100"}, + {name: "b", hash: "x", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedOrder: []string{"a", "b"}, + }, + { + testName: "branches on the same commit are asked about once", + refs: []testRef{ + {name: "mirror-1", hash: "s", date: "100"}, + {name: "mirror-2", hash: "s", date: "100"}, + {name: "stack-bottom", hash: "u", date: "100"}, + {name: "stack-top", hash: "t", date: "100"}, + }, + gitVersion: &GitVersion{2, 41, 0, ""}, + expectedArgs: []string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:s)%00%(ahead-behind:u)%00%(ahead-behind:t)", + "refs/heads/mirror-1", "refs/heads/stack-bottom", "refs/heads/stack-top", + }, + output: "refs/heads/mirror-1\x000 0\x001 1\x001 2\n" + + "refs/heads/stack-bottom\x001 1\x000 0\x000 1\n" + + "refs/heads/stack-top\x002 1\x001 0\x000 0\n", + expectedOrder: []string{"mirror-1", "mirror-2", "stack-top", "stack-bottom"}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + runner := oscommands.NewFakeRunner(t) + if s.expectedArgs != nil { + runner.ExpectGitArgs(s.expectedArgs, s.output, s.outputErr) + } + gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: s.gitVersion}) + + branches, tips := buildTestRefs(s.refs) + err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version, + branches, (*models.Branch).FullRefName, tips) + + if s.expectedErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, s.expectedErr) + } + assert.Equal(t, s.expectedOrder, branchNames(branches)) + runner.CheckForMissingCalls() + }) + } +} + +func TestSortRefsWithEqualDatesByAncestry_TooManyTips(t *testing.T) { + refs := lo.Map(lo.Range(maxTipsForAncestrySorting+1), func(i int, _ int) testRef { + return testRef{ + name: fmt.Sprintf("branch-%03d", i), + hash: fmt.Sprintf("hash-%03d", i), + date: "100", + } + }) + + // The runner fails the test if the command runs at all + runner := oscommands.NewFakeRunner(t) + gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}}) + + branches, tips := buildTestRefs(refs) + err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version, + branches, (*models.Branch).FullRefName, tips) + + assert.NoError(t, err) + assert.Equal(t, lo.Map(refs, func(ref testRef, _ int) string { return ref.name }), + branchNames(branches)) + runner.CheckForMissingCalls() +} + +// A repository with many remotes has the same branches under each of them, so +// a group can hold far more refs than the commits they point at +func TestSortRefsWithEqualDatesByAncestry_ManyRefsOnTwoTips(t *testing.T) { + refs := lo.Map(lo.Range(4*maxTipsForAncestrySorting), func(i int, _ int) testRef { + return testRef{ + name: fmt.Sprintf("branch-%03d", i), + hash: lo.Ternary(i%2 == 0, "bottom", "top"), + date: "100", + } + }) + + runner := oscommands.NewFakeRunner(t).ExpectGitArgs([]string{ + "for-each-ref", + "--format=%(refname)%00%(ahead-behind:bottom)%00%(ahead-behind:top)", + "refs/heads/branch-000", "refs/heads/branch-001", + }, + "refs/heads/branch-000\x000 0\x000 1\n"+ + "refs/heads/branch-001\x001 0\x000 0\n", nil) + gitCommon := buildGitCommon(commonDeps{runner: runner, gitVersion: &GitVersion{2, 41, 0, ""}}) + + branches, tips := buildTestRefs(refs) + err := sortRefsWithEqualDatesByAncestry(gitCommon.cmd, gitCommon.version, + branches, (*models.Branch).FullRefName, tips) + + assert.NoError(t, err) + // the branches on the top commit first, each half still ordered by name + expected := append( + lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) { + return ref.name, ref.hash == "top" + }), + lo.FilterMap(refs, func(ref testRef, _ int) (string, bool) { + return ref.name, ref.hash == "bottom" + })...) + assert.Equal(t, expected, branchNames(branches)) + runner.CheckForMissingCalls() +} diff --git a/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go b/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go new file mode 100644 index 000000000..913f3e9ed --- /dev/null +++ b/pkg/integration/tests/branch/sort_local_branches_in_stack_order.go @@ -0,0 +1,52 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SortLocalBranchesInStackOrder = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Sort each group of branches that share a committer date so that a branch comes before the ones it is based on", + ExtraCmdArgs: []string{}, + Skip: false, + GitVersion: AtLeast("2.41.0"), + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "date" + }, + SetupRepo: func(shell *Shell) { + // Rebasing a stack of branches gives every commit it creates the same + // committer date; give each of these two stacks one date of its own for + // the same effect. + dateOfFirstStack := "2024-01-01 10:00:00" + dateOfSecondStack := "2024-01-01 11:00:00" + + shell. + EmptyCommitWithDate("base", dateOfFirstStack). + NewBranch("one-bottom"). + EmptyCommitWithDate("one-bottom", dateOfFirstStack). + NewBranch("one-middle"). + EmptyCommitWithDate("one-middle", dateOfFirstStack). + NewBranch("one-top"). + EmptyCommitWithDate("one-top", dateOfFirstStack). + NewBranchFrom("unrelated", "master"). + EmptyCommitWithDate("unrelated", dateOfFirstStack). + NewBranchFrom("two-bottom", "master"). + EmptyCommitWithDate("two-bottom", dateOfSecondStack). + NewBranch("two-top"). + EmptyCommitWithDate("two-top", dateOfSecondStack). + Checkout("master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("two-top"), + Contains("two-bottom"), + Contains("one-top"), + Contains("one-middle"), + Contains("one-bottom"), + Contains("unrelated"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ef73e79b1..c7584bb35 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -86,6 +86,7 @@ var tests = []*components.IntegrationTest{ branch.ShowDivergenceFromUpstream, branch.ShowDivergenceFromUpstreamNoDivergence, branch.SortLocalBranches, + branch.SortLocalBranchesInStackOrder, branch.SortRemoteBranches, branch.SquashMerge, branch.Suggestions,