diff --git a/docs-master/Stacked_Branches.md b/docs-master/Stacked_Branches.md index f961e74fd..6bbd7ae56 100644 --- a/docs-master/Stacked_Branches.md +++ b/docs-master/Stacked_Branches.md @@ -15,7 +15,9 @@ branches properly stacked onto it. Lazygit visualizes the individual branch heads in the stack by marking them with a cyan asterisk (or a cyan branch symbol if you are using [nerd -fonts](Config.md#display-nerd-fonts-icons)). +fonts](Config.md#display-nerd-fonts-icons)). In the commits panel, `>` selects +the first commit of the branch that the selection is in. Press it again to get +to the first commit of the next branch down the stack. When you push the topmost branch of the stack with `P`, and the branches below it have commits that haven't been pushed yet, lazygit offers to push them along diff --git a/pkg/gui/branchstack/branchstack.go b/pkg/gui/branchstack/branchstack.go new file mode 100644 index 000000000..5455fe442 --- /dev/null +++ b/pkg/gui/branchstack/branchstack.go @@ -0,0 +1,92 @@ +package branchstack + +import ( + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/samber/lo" +) + +// Returns the branches stacked below the branch with the given name: those +// whose tip is one of the given commits of the branch that hasn't been +// merged into a main branch yet. The branch itself and the main branches +// are left out. The result is in the order in which the tips appear in +// commits, so the branch closest to the given one comes first. +// +// A commit counts as merged if the remote branch of a main branch contains it +// (this is what the commit's status says), but also if the local main branch +// contains it. So if a branch was merged into the local main branch, it isn't +// stacked below a branch on top of that main branch, and if the given branch +// is itself a main branch, nothing is stacked below it. +// +// Only the commits passed in are looked at, so a branch whose tip is further +// down the history than the loaded commits is not found. +func BranchesBelow( + commits []*models.Commit, + branches []*models.Branch, + branchName string, + mainBranches []string, +) []*models.Branch { + branchesByTip := map[string][]*models.Branch{} + for _, other := range branches { + if !lo.Contains(mainBranches, other.Name) && other.Name != branchName { + branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) + } + } + + mergedLocally := CommitsMergedIntoLocalMainBranch(commits, branches, mainBranches) + + result := []*models.Branch{} + for _, commit := range commits { + if commit.IsTODO() || commit.Status == models.StatusMerged || mergedLocally.Includes(commit.Hash()) { + continue + } + result = append(result, branchesByTip[commit.Hash()]...) + } + + return result +} + +// Returns the hashes of those of the given commits that a local main branch +// contains, but whose status doesn't say that they are merged. The status +// looks at the remote branch of a main branch if it has one, so these are +// commits that were merged into a local main branch but not pushed yet. +func CommitsMergedIntoLocalMainBranch( + commits []*models.Commit, + branches []*models.Branch, + mainBranches []string, +) *set.Set[string] { + result := set.New[string]() + + mainBranchTips := lo.FilterMap(branches, func(branch *models.Branch, _ int) (string, bool) { + return branch.CommitHash, lo.Contains(mainBranches, branch.Name) + }) + if len(mainBranchTips) == 0 { + return result + } + + // The parents of a merged commit are merged too, so the walk never needs + // to go past a merged commit. Usually the tip of a local main branch is + // merged itself, so there is nothing to walk; this keeps it cheap enough to + // do on every render of the commits list. + unmergedCommitsByHash := map[string]*models.Commit{} + for _, commit := range commits { + if !commit.IsTODO() && commit.Status != models.StatusMerged { + unmergedCommitsByHash[commit.Hash()] = commit + } + } + + pending := mainBranchTips + for len(pending) > 0 { + hash := pending[len(pending)-1] + pending = pending[:len(pending)-1] + + commit, ok := unmergedCommitsByHash[hash] + if !ok || result.Includes(hash) { + continue + } + result.Add(hash) + pending = append(pending, commit.Parents()...) + } + + return result +} diff --git a/pkg/gui/controllers/helpers/branch_stack_test.go b/pkg/gui/branchstack/branchstack_test.go similarity index 97% rename from pkg/gui/controllers/helpers/branch_stack_test.go rename to pkg/gui/branchstack/branchstack_test.go index d967acc9e..72bd5cb03 100644 --- a/pkg/gui/controllers/helpers/branch_stack_test.go +++ b/pkg/gui/branchstack/branchstack_test.go @@ -1,4 +1,4 @@ -package helpers +package branchstack import ( "testing" @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestBranchesBelowInStack(t *testing.T) { +func TestBranchesBelow(t *testing.T) { hashPool := &utils.StringPool{} commit := func(hash string, status models.CommitStatus, parents ...string) *models.Commit { return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status, Parents: parents}) @@ -141,7 +141,7 @@ func TestBranchesBelowInStack(t *testing.T) { if s.current != nil { branch = s.current } - result := BranchesBelowInStack(s.commits, s.branches, branch, mainBranches) + result := BranchesBelow(s.commits, s.branches, branch.Name, mainBranches) names := lo.Map(result, func(b *models.Branch, _ int) string { return b.Name }) assert.Equal(t, s.expected, names) }) diff --git a/pkg/gui/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go new file mode 100644 index 000000000..92da10b17 --- /dev/null +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -0,0 +1,108 @@ +package context + +import ( + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/samber/lo" + "github.com/stefanhaller/git-todo-parser/todo" +) + +// Returns the index of the commit that the "go to bottom" command selects in a +// list of commits. If the list has several sections, the command first goes to +// the beginning of the section that the selection is in, which is its oldest +// commit. From there it goes to the beginning of the next section, and from the +// last section to the bottom of the list. +// +// The sections are: +// - the todos of a rebase, cherry-pick or revert +// - each branch of a stack, given by branchesBelow. Among the todos of a +// rebase, update-ref todos separate the branches. +// - the commits that aren't merged into a main branch yet +// - the remote commits of a divergence view, which are above the local ones +// +// During a bisect, it stops at the bottommost commit marked bad, at the commit +// to test, and at the topmost commit marked good instead. The commits list +// doesn't color the commits by whether they are merged during a bisect, so the +// beginnings of the sections would look like arbitrary commits. +func indexForGotoBottomInCommits( + commits []*models.Commit, + selectedIdx int, + branchesBelow []*models.Branch, + bisectInfo *git_commands.BisectInfo, +) int { + if bisectInfo.Started() { + return closestStopBelow(bisectStops(commits, bisectInfo), selectedIdx, len(commits)-1) + } + + stops := []int{} + + if headIdx := models.HeadCommitIdx(commits); headIdx > 0 { + stops = append(stops, headIdx-1) + } + + tipsOfBranchesBelow := set.NewFromSlice(lo.Map(branchesBelow, func(branch *models.Branch, _ int) string { + return branch.CommitHash + })) + for idx, commit := range commits { + if commit.IsTODO() { + // If several update-ref todos follow each other, the branches + // between them have no todos, so only stop above the first one + if commit.Action == todo.UpdateRef && idx > 0 && commits[idx-1].Action != todo.UpdateRef { + stops = append(stops, idx-1) + } + } else if tipsOfBranchesBelow.Includes(commit.Hash()) { + stops = append(stops, idx-1) + } + } + + if _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { + return c.Status == models.StatusMerged + }); found { + stops = append(stops, idx-1) + } + + if _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { + return c.Divergence == models.DivergenceLeft + }); found { + stops = append(stops, idx-1) + } + + return closestStopBelow(stops, selectedIdx, len(commits)-1) +} + +func bisectStops(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) []int { + hasStatus := func(status git_commands.BisectStatus) func(*models.Commit) bool { + return func(commit *models.Commit) bool { + commitStatus, ok := bisectInfo.Status(commit.Hash()) + return ok && commitStatus == status + } + } + isCommitToTest := func(commit *models.Commit) bool { + return bisectInfo.HasCommitToTest() && commit.Hash() == bisectInfo.GetCurrentHash() + } + + stops := []int{} + if _, idx, found := lo.FindLastIndexOf(commits, hasStatus(git_commands.BisectStatusNew)); found { + stops = append(stops, idx) + } + if _, idx, found := lo.FindIndexOf(commits, isCommitToTest); found { + stops = append(stops, idx) + } + if _, idx, found := lo.FindIndexOf(commits, hasStatus(git_commands.BisectStatusOld)); found { + stops = append(stops, idx) + } + return stops +} + +// Returns the smallest of the given stops that is greater than the selected +// index, or the last index if there is none +func closestStopBelow(stops []int, selectedIdx int, lastIdx int) int { + result := lastIdx + for _, stop := range stops { + if stop > selectedIdx && stop < result { + result = stop + } + } + return result +} diff --git a/pkg/gui/context/commit_list_goto_bottom_test.go b/pkg/gui/context/commit_list_goto_bottom_test.go new file mode 100644 index 000000000..b6b653b85 --- /dev/null +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -0,0 +1,225 @@ +package context + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stefanhaller/git-todo-parser/todo" + "github.com/stretchr/testify/assert" +) + +func TestIndexForGotoBottomInCommits(t *testing.T) { + hashPool := &utils.StringPool{} + commit := func(hash string, status models.CommitStatus) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Status: status}) + } + todoCommit := func(hash string, action todo.TodoCommand) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Action: action, Status: models.StatusRebasing}) + } + + branchCommits := []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("c2", models.StatusUnpushed), + commit("c1", models.StatusPushed), + commit("m2", models.StatusMerged), + commit("m1", models.StatusMerged), + } + + rebaseCommits := []*models.Commit{ + todoCommit("t3", todo.Pick), + todoCommit("t2", todo.Pick), + todoCommit("t1", todo.Edit), + commit("c1", models.StatusUnpushed), + commit("m1", models.StatusMerged), + } + + updateRefTodo := func(branchName string) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{ + Name: "refs/heads/" + branchName, Action: todo.UpdateRef, Status: models.StatusRebasing, + }) + } + divergentCommit := func(hash string, divergence models.Divergence) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash, Divergence: divergence}) + } + branch := func(name string, tip string) *models.Branch { + return &models.Branch{Name: name, CommitHash: tip} + } + + stackCommits := []*models.Commit{ + commit("c4", models.StatusUnpushed), + commit("c3", models.StatusUnpushed), + commit("c2", models.StatusPushed), + commit("c1", models.StatusUnpushed), + commit("m1", models.StatusMerged), + } + + stackRebaseCommits := []*models.Commit{ + todoCommit("t4", todo.Pick), + todoCommit("t3", todo.Pick), + updateRefTodo("lower"), + todoCommit("t2", todo.Pick), + todoCommit("t1", todo.Pick), + commit("c1", models.StatusUnpushed), + commit("m1", models.StatusMerged), + } + + divergenceCommits := []*models.Commit{ + divergentCommit("r2", models.DivergenceRight), + divergentCommit("r1", models.DivergenceRight), + divergentCommit("l3", models.DivergenceLeft), + divergentCommit("l2", models.DivergenceLeft), + divergentCommit("l1", models.DivergenceLeft), + } + + scenarios := []struct { + testName string + selectedIdx int + commits []*models.Commit + branchesBelow []*models.Branch + expected int + }{ + { + testName: "goes to the beginning of the branch first", + selectedIdx: 0, + commits: branchCommits, + expected: 2, + }, + { + testName: "goes from the beginning of the branch to the bottom", + selectedIdx: 2, + commits: branchCommits, + expected: 4, + }, + { + testName: "goes from a merged commit to the bottom", + selectedIdx: 3, + commits: branchCommits, + expected: 4, + }, + { + testName: "goes to the bottom if no commit is merged", + selectedIdx: 0, + commits: []*models.Commit{ + commit("c2", models.StatusUnpushed), + commit("c1", models.StatusUnpushed), + }, + expected: 1, + }, + { + testName: "goes to the bottom if the branch has no commits of its own", + selectedIdx: 0, + commits: []*models.Commit{ + commit("m2", models.StatusMerged), + commit("m1", models.StatusMerged), + }, + expected: 1, + }, + { + testName: "goes down from a merged commit that an unmerged one follows", + selectedIdx: 1, + commits: []*models.Commit{ + // c3 merges m2 of master into the branch + commit("c3", models.StatusUnpushed), + commit("m2", models.StatusMerged), + commit("c2", models.StatusUnpushed), + commit("c1", models.StatusUnpushed), + commit("m1", models.StatusMerged), + }, + expected: 4, + }, + { + testName: "goes to the first todo of a rebase", + selectedIdx: 0, + commits: rebaseCommits, + expected: 2, + }, + { + testName: "goes from the first todo of a rebase to the beginning of the branch", + selectedIdx: 2, + commits: rebaseCommits, + expected: 3, + }, + { + testName: "goes to the conflicted commit at the bottom of the todos", + selectedIdx: 0, + commits: []*models.Commit{ + todoCommit("t2", todo.Pick), + models.NewCommit(hashPool, models.NewCommitOpts{Hash: "t1", Action: todo.Pick, Status: models.StatusConflicted}), + commit("c1", models.StatusUnpushed), + }, + expected: 1, + }, + { + testName: "goes to the beginning of the branch of a stack that the selection is in", + selectedIdx: 0, + commits: stackCommits, + branchesBelow: []*models.Branch{branch("lower", "c2")}, + expected: 1, + }, + { + testName: "goes to the beginning of the lower branch of a stack from the upper one", + selectedIdx: 1, + commits: stackCommits, + branchesBelow: []*models.Branch{branch("lower", "c2")}, + expected: 3, + }, + { + testName: "goes to the beginning of the branch of a stack among the todos of a rebase", + selectedIdx: 0, + commits: stackRebaseCommits, + branchesBelow: []*models.Branch{}, + expected: 1, + }, + { + testName: "goes from the beginning of a branch among the todos to the first todo", + selectedIdx: 1, + commits: stackRebaseCommits, + branchesBelow: []*models.Branch{}, + expected: 4, + }, + { + testName: "goes past update-ref todos without todos between them", + selectedIdx: 0, + commits: []*models.Commit{ + todoCommit("t3", todo.Pick), + updateRefTodo("upper"), + updateRefTodo("lower"), + todoCommit("t2", todo.Pick), + todoCommit("t1", todo.Pick), + commit("c1", models.StatusUnpushed), + }, + branchesBelow: []*models.Branch{}, + expected: 4, + }, + { + testName: "goes to the beginning of the remote commits of a divergence view", + selectedIdx: 0, + commits: divergenceCommits, + expected: 1, + }, + { + testName: "goes from the remote commits of a divergence view to the bottom", + selectedIdx: 1, + commits: divergenceCommits, + expected: 4, + }, + { + testName: "goes to the bottom of a divergence view without remote commits", + selectedIdx: 0, + commits: []*models.Commit{ + divergentCommit("l2", models.DivergenceLeft), + divergentCommit("l1", models.DivergenceLeft), + }, + expected: 1, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.Equal(t, s.expected, indexForGotoBottomInCommits( + s.commits, s.selectedIdx, s.branchesBelow, git_commands.NewNullBisectInfo())) + }) + } +} diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index e9faf3911..6d63c1efd 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/branchstack" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/style" @@ -520,18 +521,9 @@ func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPosi } func (self *LocalCommitsContext) IndexForGotoBottom() int { + c := self.ListContextTrait.c commits := self.GetCommits() - selectedIdx := self.GetSelectedLineIdx() - if selectedIdx >= 0 && selectedIdx < len(commits)-1 { - if commits[selectedIdx+1].Status != models.StatusMerged { - _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { - return c.Status == models.StatusMerged - }) - if found { - return idx - 1 - } - } - } - - return self.list.Len() - 1 + branchesBelow := branchstack.BranchesBelow( + commits, c.Model().Branches, c.Model().CheckedOutBranch, c.UserConfig().Git.MainBranches) + return indexForGotoBottomInCommits(commits, self.GetSelectedLineIdx(), branchesBelow, c.Model().BisectInfo) } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 73d3022b5..edada3e52 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/branchstack" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -270,17 +271,11 @@ func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitiv func (self *SubCommitsContext) IndexForGotoBottom() int { commits := self.GetCommits() - selectedIdx := self.GetSelectedLineIdx() - if selectedIdx >= 0 && selectedIdx < len(commits)-1 { - if commits[selectedIdx+1].Status != models.StatusMerged { - _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { - return c.Status == models.StatusMerged - }) - if found { - return idx - 1 - } - } + var branchesBelow []*models.Branch + if self.GetShowBranchHeads() { + branchesBelow = branchstack.BranchesBelow( + commits, self.c.Model().Branches, self.GetRef().RefName(), self.c.UserConfig().Git.MainBranches) } - - return self.list.Len() - 1 + return indexForGotoBottomInCommits( + commits, self.GetSelectedLineIdx(), branchesBelow, git_commands.NewNullBisectInfo()) } diff --git a/pkg/gui/controllers/helpers/branch_stack.go b/pkg/gui/controllers/helpers/branch_stack.go deleted file mode 100644 index b81d1669f..000000000 --- a/pkg/gui/controllers/helpers/branch_stack.go +++ /dev/null @@ -1,77 +0,0 @@ -package helpers - -import ( - "github.com/jesseduffield/generics/set" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/samber/lo" -) - -// Returns the branches stacked below the given branch: those whose tip is one -// of the given commits of the branch that hasn't been merged into a main -// branch yet. The branch itself and the main branches are left out. The -// result is in the order in which the tips appear in commits, so the branch -// closest to the given one comes first. -// -// A commit counts as merged if the remote branch of a main branch contains it -// (this is what the commit's status says), but also if the local main branch -// contains it. So if a branch was merged into the local main branch, it isn't -// stacked below a branch on top of that main branch, and if the given branch -// is itself a main branch, nothing is stacked below it. -// -// Only the commits passed in are looked at, so a branch whose tip is further -// down the history than the loaded commits is not found. -func BranchesBelowInStack( - commits []*models.Commit, - branches []*models.Branch, - branch *models.Branch, - mainBranches []string, -) []*models.Branch { - branchesByTip := map[string][]*models.Branch{} - mainBranchTips := []string{} - for _, other := range branches { - if lo.Contains(mainBranches, other.Name) { - mainBranchTips = append(mainBranchTips, other.CommitHash) - } else if other.Name != branch.Name { - branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) - } - } - - mergedLocally := reachableCommits(commits, mainBranchTips) - - result := []*models.Branch{} - for _, commit := range commits { - if commit.IsTODO() || commit.Status == models.StatusMerged || mergedLocally.Includes(commit.Hash()) { - continue - } - result = append(result, branchesByTip[commit.Hash()]...) - } - - return result -} - -// Returns the hashes of those of the given commits that can be reached from -// any of the given tips by following their parents -func reachableCommits(commits []*models.Commit, tips []string) *set.Set[string] { - commitsByHash := map[string]*models.Commit{} - for _, commit := range commits { - if !commit.IsTODO() { - commitsByHash[commit.Hash()] = commit - } - } - - reachable := set.New[string]() - pending := append([]string{}, tips...) - for len(pending) > 0 { - hash := pending[len(pending)-1] - pending = pending[:len(pending)-1] - - commit, ok := commitsByHash[hash] - if !ok || reachable.Includes(hash) { - continue - } - reachable.Add(hash) - pending = append(pending, commit.Parents()...) - } - - return reachable -} diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 260b5a2fb..f4ba5388b 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -8,7 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" + "github.com/jesseduffield/lazygit/pkg/gui/branchstack" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -128,8 +128,8 @@ func (self *SyncController) pushCurrentBranch(currentBranch *models.Branch) erro // The branches stacked below the current one that have commits to push func (self *SyncController) unpushedBranchesBelow(currentBranch *models.Branch) []*models.Branch { - branchesBelow := helpers.BranchesBelowInStack( - self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) + branchesBelow := branchstack.BranchesBelow( + self.c.Model().Commits, self.c.Model().Branches, currentBranch.Name, self.c.UserConfig().Git.MainBranches) return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool { // Pushing a branch whose remote branch was deleted would recreate it. @@ -289,8 +289,8 @@ func (self *SyncController) pullCurrentBranch(currentBranch *models.Branch, bran // upstream branches without losing anything: those that are behind them, and // those that diverged from them only because they were rewritten func (self *SyncController) updatableBranchesBelow(currentBranch *models.Branch) []*models.Branch { - branchesBelow := helpers.BranchesBelowInStack( - self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) + branchesBelow := branchstack.BranchesBelow( + self.c.Model().Commits, self.c.Model().Branches, currentBranch.Name, self.c.UserConfig().Git.MainBranches) return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool { // Updating a branch that is checked out in another worktree changes diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 28fc356bf..8058ad03a 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/gui/branchstack" "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors" "github.com/jesseduffield/lazygit/pkg/gui/presentation/graph" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" @@ -182,6 +183,9 @@ func GetCommitListDisplayStrings( commits, common.UserConfig().Gui.CommitHashLength, fullDescription, timeFormat, shortTimeFormat, now, bisectInfo) + mergedIntoLocalMainBranch := branchstack.CommitsMergedIntoLocalMainBranch( + commits, branches, common.UserConfig().Git.MainBranches) + lines := make([][]string, 0, len(filteredCommits)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" @@ -196,6 +200,7 @@ func GetCommitListDisplayStrings( common, commit, branchHeadsToVisualize, + mergedIntoLocalMainBranch.Includes(commit.Hash()), hasRebaseUpdateRefsConfig, cherryPickedCommitHashSet, isMarkedBaseCommit, @@ -401,6 +406,7 @@ func displayCommit( common *common.Common, commit *models.Commit, branchHeadsToVisualize *set.Set[string], + isMergedIntoLocalMainBranch bool, hasRebaseUpdateRefsConfig bool, cherryPickedCommitHashSet *set.Set[string], isMarkedBaseCommit bool, @@ -457,8 +463,9 @@ func displayCommit( } if branchHeadsToVisualize.Includes(commit.Hash()) && - // Don't show branch head on commits that are already merged to a main branch - commit.Status != models.StatusMerged && + // Don't show branch head on commits that are already merged to a + // main branch, be it its remote branch or the local one + commit.Status != models.StatusMerged && !isMergedIntoLocalMainBranch && // Don't show branch head on a "pick" todo if the rebase.updateRefs config is on !(commit.IsTODO() && hasRebaseUpdateRefsConfig) { tagString = style.FgCyan.SetBold().Sprint( diff --git a/pkg/gui/presentation/commits_test.go b/pkg/gui/presentation/commits_test.go index 079c8c4da..c000221eb 100644 --- a/pkg/gui/presentation/commits_test.go +++ b/pkg/gui/presentation/commits_test.go @@ -126,6 +126,37 @@ func TestGetCommitListDisplayStrings(t *testing.T) { hash4 commit4 `), }, + { + testName: "don't show branch heads of branches merged into a local main branch", + commitOpts: []models.NewCommitOpts{ + {Name: "commit1", Hash: "hash1", Parents: []string{"hash2"}}, + {Name: "commit2", Hash: "hash2", Parents: []string{"hash3"}}, + {Name: "commit3", Hash: "hash3", Parents: []string{"hash4"}}, + {Name: "commit4", Hash: "hash4", Parents: []string{"hash5"}}, + {Name: "commit5", Hash: "hash5", Status: models.StatusMerged}, + }, + branches: []*models.Branch{ + {Name: "current-branch", CommitHash: "hash1", Head: true}, + {Name: "other-branch", CommitHash: "hash2", Head: false}, + {Name: "master", CommitHash: "hash3", Head: false}, + {Name: "merged-into-local-master", CommitHash: "hash4", Head: false}, + }, + currentBranchName: "current-branch", + hasUpdateRefConfig: true, + startIdx: 0, + endIdx: 5, + showGraph: false, + bisectInfo: git_commands.NewNullBisectInfo(), + cherryPickedCommitHashSet: set.New[string](), + now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + expected: formatExpected(` + hash1 commit1 + hash2 * commit2 + hash3 commit3 + hash4 commit4 + hash5 commit5 + `), + }, { testName: "show local branch head for head commit if updateRefs is on", commitOpts: []models.NewCommitOpts{ diff --git a/pkg/integration/tests/bisect/goto_bottom.go b/pkg/integration/tests/bisect/goto_bottom.go new file mode 100644 index 000000000..10f31be1a --- /dev/null +++ b/pkg/integration/tests/bisect/goto_bottom.go @@ -0,0 +1,47 @@ +package bisect + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var GotoBottom = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Go to the bottom of the commits list in steps, stopping only at the bad commit, the current commit, and the first good commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(3). + NewBranch("mybranch"). + CreateNCommitsStartingAt(7, 4). + StartBisect("HEAD~1", "HEAD~8") + }, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Git.Log.ShowGraph = "never" + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("CI commit-10"), + Contains("CI commit-09").Contains("<-- bad"), + Contains("CI commit-08"), + Contains("CI commit-07"), + Contains("CI commit-06"), + Contains("CI commit-05").Contains("<-- current").IsSelected(), + Contains("CI commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02").Contains("<-- good"), + Contains("CI commit-01"), + ). + Press(keys.Universal.GotoTop). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("CI commit-09")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("CI commit-05")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("CI commit-02")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("CI commit-01")) + }, +}) diff --git a/pkg/integration/tests/commit/goto_bottom_in_branch_stack.go b/pkg/integration/tests/commit/goto_bottom_in_branch_stack.go new file mode 100644 index 000000000..cc9592d5d --- /dev/null +++ b/pkg/integration/tests/commit/goto_bottom_in_branch_stack.go @@ -0,0 +1,82 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var GotoBottomInBranchStack = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Go to the bottom of the commits list in steps, stopping at the beginning of each branch of a stack", + ExtraCmdArgs: []string{}, + Skip: false, + GitVersion: AtLeast("2.38.0"), + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(2). + NewBranch("branch1"). + CreateNCommitsStartingAt(2, 3). + NewBranch("branch2"). + CreateNCommitsStartingAt(2, 5) + + shell.SetConfig("rebase.updateRefs", "true") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("CI ○ commit-06").IsSelected(), + Contains("CI ○ commit-05"), + Contains("CI ○ * commit-04"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), + ). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-05")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-03")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-01")) + + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("branch2")). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + SelectedLine(Contains("commit-06")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-05")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-03")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-01")) + + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("commit-03")). + Press(keys.Universal.Edit). + NavigateToLine(Contains("commit-06")). + Lines( + Contains("─── Pending rebase todos"), + Contains("pick").Contains("CI commit-06").IsSelected(), + Contains("pick").Contains("CI commit-05"), + Contains("update-ref").Contains("branch1"), + Contains("pick").Contains("CI commit-04"), + Contains("─── Commits"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), + ). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-05")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-04")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-03")). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit-01")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d7ef9eec2..09ba2a464 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -39,6 +39,7 @@ var tests = []*components.IntegrationTest{ bisect.CheckOutBranchBeforeBisecting, bisect.ChooseTerms, bisect.FromOtherBranch, + bisect.GotoBottom, bisect.MarkCandidatesWhileTestingMergeBase, bisect.OfferResetWhenMergeBaseIsBad, bisect.SelectCurrentCommit, @@ -156,6 +157,7 @@ var tests = []*components.IntegrationTest{ commit.FindBaseCommitForFixupDisregardMainBranch, commit.FindBaseCommitForFixupOnlyAddedLines, commit.FindBaseCommitForFixupWarningForAddedLines, + commit.GotoBottomInBranchStack, commit.Highlight, commit.History, commit.HistoryComplex,