From 4ba8f7c17525ab3aad58c1de3d93f82476718b87 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:36:41 +0100 Subject: [PATCH 01/11] Move BranchesBelowInStack to a new branchstack package The commits list will need it to mark the branches of a stack, and so will the "go to bottom" command of the commits panel to find their beginnings. Neither the presentation nor the context package can import helpers. Rename it to BranchesBelow, since the name of the package says that it is about stacks. Co-Authored-By: Claude Opus 5.5 --- .../helpers/branch_stack.go => branchstack/branchstack.go} | 4 ++-- .../branchstack_test.go} | 6 +++--- pkg/gui/controllers/sync_controller.go | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) rename pkg/gui/{controllers/helpers/branch_stack.go => branchstack/branchstack.go} (98%) rename pkg/gui/{controllers/helpers/branch_stack_test.go => branchstack/branchstack_test.go} (97%) diff --git a/pkg/gui/controllers/helpers/branch_stack.go b/pkg/gui/branchstack/branchstack.go similarity index 98% rename from pkg/gui/controllers/helpers/branch_stack.go rename to pkg/gui/branchstack/branchstack.go index b81d1669f..b9e9db75d 100644 --- a/pkg/gui/controllers/helpers/branch_stack.go +++ b/pkg/gui/branchstack/branchstack.go @@ -1,4 +1,4 @@ -package helpers +package branchstack import ( "github.com/jesseduffield/generics/set" @@ -20,7 +20,7 @@ import ( // // 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( +func BranchesBelow( commits []*models.Commit, branches []*models.Branch, branch *models.Branch, 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..90a2ead6c 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, 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/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 260b5a2fb..3f4cd4cdd 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,7 +128,7 @@ 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( + branchesBelow := branchstack.BranchesBelow( self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool { @@ -289,7 +289,7 @@ 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( + branchesBelow := branchstack.BranchesBelow( self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) return lo.Filter(branchesBelow, func(branch *models.Branch, _ int) bool { From c84a181de9b8a7f545376554690a5b8cb520e3db Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:38:05 +0100 Subject: [PATCH 02/11] Extract finding the commits merged into a local main branch The commits list will need this to decide which branches to mark. It renders the list again whenever the selection changes, so stop the walk at merged commits. Their parents are merged too, and a local main branch is usually merged itself, so there is mostly nothing left to walk. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/branchstack/branchstack.go | 49 +++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/pkg/gui/branchstack/branchstack.go b/pkg/gui/branchstack/branchstack.go index b9e9db75d..97e88cb31 100644 --- a/pkg/gui/branchstack/branchstack.go +++ b/pkg/gui/branchstack/branchstack.go @@ -27,16 +27,13 @@ func BranchesBelow( 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 { + if !lo.Contains(mainBranches, other.Name) && other.Name != branch.Name { branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) } } - mergedLocally := reachableCommits(commits, mainBranchTips) + mergedLocally := CommitsMergedIntoLocalMainBranch(commits, branches, mainBranches) result := []*models.Branch{} for _, commit := range commits { @@ -49,29 +46,47 @@ func BranchesBelow( 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{} +// 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() { - commitsByHash[commit.Hash()] = commit + if !commit.IsTODO() && commit.Status != models.StatusMerged { + unmergedCommitsByHash[commit.Hash()] = commit } } - reachable := set.New[string]() - pending := append([]string{}, tips...) + pending := mainBranchTips for len(pending) > 0 { hash := pending[len(pending)-1] pending = pending[:len(pending)-1] - commit, ok := commitsByHash[hash] - if !ok || reachable.Includes(hash) { + commit, ok := unmergedCommitsByHash[hash] + if !ok || result.Includes(hash) { continue } - reachable.Add(hash) + result.Add(hash) pending = append(pending, commit.Parents()...) } - return reachable + return result } From 89c6f0ce5a223282eeb0311677f684190b29a55d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:40:06 +0100 Subject: [PATCH 03/11] Don't mark branches merged into a local main branch in the commits list If a branch was merged into a local main branch, but the main branch wasn't pushed yet, the commits list still marks the head of the branch as part of a stack. For example, after merging wip into master locally, the commits of master show a marker at the head of wip. Pushing and pulling a stack leave such branches out since #6169, and the markers disappear as soon as the main branch is pushed. Leave them out of the markers too, so that the markers show the same stack. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/presentation/commits.go | 11 ++++++++-- pkg/gui/presentation/commits_test.go | 31 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) 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{ From 97aeaf77a5b7542c51896b3ad0dbe8e4beb0d3e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:40:28 +0100 Subject: [PATCH 04/11] Unify the "go to bottom" logic of the two commit list contexts The local commits and the sub-commits contexts had identical copies of it. We are going to make it smarter, so make sure the copies don't drift apart. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/context/commit_list_goto_bottom.go | 23 ++++++++++++++++++++++ pkg/gui/context/local_commits_context.go | 15 +------------- pkg/gui/context/sub_commits_context.go | 15 +------------- 3 files changed, 25 insertions(+), 28 deletions(-) create mode 100644 pkg/gui/context/commit_list_goto_bottom.go 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..f05e9dffc --- /dev/null +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -0,0 +1,23 @@ +package context + +import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/samber/lo" +) + +// Returns the index of the commit that the "go to bottom" command selects in a +// list of commits +func indexForGotoBottomInCommits(commits []*models.Commit, selectedIdx int) int { + 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 len(commits) - 1 +} diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index e9faf3911..a00783147 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -520,18 +520,5 @@ func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPosi } func (self *LocalCommitsContext) 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 - } - } - } - - return self.list.Len() - 1 + return indexForGotoBottomInCommits(self.GetCommits(), self.GetSelectedLineIdx()) } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 73d3022b5..c6bad0f45 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -269,18 +269,5 @@ 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 - } - } - } - - return self.list.Len() - 1 + return indexForGotoBottomInCommits(self.GetCommits(), self.GetSelectedLineIdx()) } From 381c532b14b65ecd99e5c589c154c1191c64ab79 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:41:25 +0100 Subject: [PATCH 05/11] Add a test showing that "go to bottom" in the commits panel can go up If the history of a branch interleaves merged and unmerged commits, for example because master was merged into the branch, pressing '>' on a merged commit that an unmerged one follows selects a commit above it. Co-Authored-By: Claude Opus 5.5 --- .../context/commit_list_goto_bottom_test.go | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 pkg/gui/context/commit_list_goto_bottom_test.go 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..4bc2eac0a --- /dev/null +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -0,0 +1,90 @@ +package context + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "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}) + } + + branchCommits := []*models.Commit{ + commit("c3", models.StatusUnpushed), + commit("c2", models.StatusUnpushed), + commit("c1", models.StatusPushed), + commit("m2", models.StatusMerged), + commit("m1", models.StatusMerged), + } + + scenarios := []struct { + testName string + selectedIdx int + commits []*models.Commit + 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: + expected: 4, + ACTUAL: */ + expected: 0, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + assert.Equal(t, s.expected, indexForGotoBottomInCommits(s.commits, s.selectedIdx)) + }) + } +} From 7426d7f934ea90aa3f3ddf5bbe6d8399d9809f8c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:41:57 +0100 Subject: [PATCH 06/11] Don't let "go to bottom" in the commits panel go up The command went to the commit above the first merged one whenever the commit below the selection wasn't merged. It did this even if the selection was below that commit already. Collect the places where the command can stop, and go to the closest one below the selection, or to the bottom if there is none. This also makes it easy to add more places to stop at. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/context/commit_list_goto_bottom.go | 37 +++++++++++++------ .../context/commit_list_goto_bottom_test.go | 3 -- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/pkg/gui/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go index f05e9dffc..5fc011347 100644 --- a/pkg/gui/context/commit_list_goto_bottom.go +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -6,18 +6,33 @@ import ( ) // Returns the index of the commit that the "go to bottom" command selects in a -// list of commits +// 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 commits of the branch that aren't merged into a main branch yet func indexForGotoBottomInCommits(commits []*models.Commit, selectedIdx int) int { - 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 - } - } + stops := []int{} + + if _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { + return c.Status == models.StatusMerged + }); found { + stops = append(stops, idx-1) } - return len(commits) - 1 + return closestStopBelow(stops, selectedIdx, len(commits)-1) +} + +// 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 index 4bc2eac0a..4ed686064 100644 --- a/pkg/gui/context/commit_list_goto_bottom_test.go +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -75,10 +75,7 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { commit("c1", models.StatusUnpushed), commit("m1", models.StatusMerged), }, - /* EXPECTED: expected: 4, - ACTUAL: */ - expected: 0, }, } From 33f52279c4554628cdbed05bf95043e3f7fa1fac Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:42:48 +0100 Subject: [PATCH 07/11] Make "go to bottom" in the commits panel stop at the first todo During a rebase, cherry-pick or revert, the todos are a section of their own at the top of the commits panel. Pressing '>' now goes to the first of them, the bottommost one, before it goes on to the beginning of the branch. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/context/commit_list_goto_bottom.go | 5 +++ .../context/commit_list_goto_bottom_test.go | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pkg/gui/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go index 5fc011347..6b0f9a94c 100644 --- a/pkg/gui/context/commit_list_goto_bottom.go +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -12,10 +12,15 @@ import ( // last section to the bottom of the list. // // The sections are: +// - the todos of a rebase, cherry-pick or revert // - the commits of the branch that aren't merged into a main branch yet func indexForGotoBottomInCommits(commits []*models.Commit, selectedIdx int) int { stops := []int{} + if headIdx := models.HeadCommitIdx(commits); headIdx > 0 { + stops = append(stops, headIdx-1) + } + if _, idx, found := lo.FindIndexOf(commits, func(c *models.Commit) bool { return c.Status == models.StatusMerged }); found { diff --git a/pkg/gui/context/commit_list_goto_bottom_test.go b/pkg/gui/context/commit_list_goto_bottom_test.go index 4ed686064..b37784814 100644 --- a/pkg/gui/context/commit_list_goto_bottom_test.go +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -5,6 +5,7 @@ import ( "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" ) @@ -13,6 +14,9 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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), @@ -22,6 +26,14 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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), + } + scenarios := []struct { testName string selectedIdx int @@ -77,6 +89,28 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { }, 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, + }, } for _, s := range scenarios { From 633bedf4fdac7397b7c9cad4b7c8ab6e021f3c5b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:43:36 +0100 Subject: [PATCH 08/11] Pass only the branch name to BranchesBelow The commits panel will need to call it. All it knows is the name of the checked-out branch. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/branchstack/branchstack.go | 14 +++++++------- pkg/gui/branchstack/branchstack_test.go | 2 +- pkg/gui/controllers/sync_controller.go | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/gui/branchstack/branchstack.go b/pkg/gui/branchstack/branchstack.go index 97e88cb31..5455fe442 100644 --- a/pkg/gui/branchstack/branchstack.go +++ b/pkg/gui/branchstack/branchstack.go @@ -6,11 +6,11 @@ import ( "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. +// 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 @@ -23,12 +23,12 @@ import ( func BranchesBelow( commits []*models.Commit, branches []*models.Branch, - branch *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 != branch.Name { + if !lo.Contains(mainBranches, other.Name) && other.Name != branchName { branchesByTip[other.CommitHash] = append(branchesByTip[other.CommitHash], other) } } diff --git a/pkg/gui/branchstack/branchstack_test.go b/pkg/gui/branchstack/branchstack_test.go index 90a2ead6c..72bd5cb03 100644 --- a/pkg/gui/branchstack/branchstack_test.go +++ b/pkg/gui/branchstack/branchstack_test.go @@ -141,7 +141,7 @@ func TestBranchesBelow(t *testing.T) { if s.current != nil { branch = s.current } - result := BranchesBelow(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/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 3f4cd4cdd..f4ba5388b 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -129,7 +129,7 @@ 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 := branchstack.BranchesBelow( - self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) + 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. @@ -290,7 +290,7 @@ func (self *SyncController) pullCurrentBranch(currentBranch *models.Branch, bran // those that diverged from them only because they were rewritten func (self *SyncController) updatableBranchesBelow(currentBranch *models.Branch) []*models.Branch { branchesBelow := branchstack.BranchesBelow( - self.c.Model().Commits, self.c.Model().Branches, currentBranch, self.c.UserConfig().Git.MainBranches) + 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 From 462a168d87bcb93ca12e4d6b5a52893f916aaf0f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:46:15 +0100 Subject: [PATCH 09/11] Make "go to bottom" stop at the beginning of each branch of a stack In a stack of branches, pressing '>' in the commits panel went to the beginning of the bottommost branch right away. Make it go to the beginning of the branch that the selection is in first, and then to the beginning of each branch below it. During a rebase, the update-ref todos separate the branches among the todos, so stop above each of them too. The branches of the stack are the ones that the commits list marks, and that pushing and pulling a stack include. Co-Authored-By: Claude Opus 5.5 --- docs-master/Stacked_Branches.md | 4 +- pkg/gui/context/commit_list_goto_bottom.go | 25 +++++- .../context/commit_list_goto_bottom_test.go | 80 ++++++++++++++++-- pkg/gui/context/local_commits_context.go | 7 +- pkg/gui/context/sub_commits_context.go | 9 +- .../commit/goto_bottom_in_branch_stack.go | 82 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 7 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 pkg/integration/tests/commit/goto_bottom_in_branch_stack.go 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/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go index 6b0f9a94c..3d955f628 100644 --- a/pkg/gui/context/commit_list_goto_bottom.go +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -1,8 +1,10 @@ package context import ( + "github.com/jesseduffield/generics/set" "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 @@ -13,14 +15,33 @@ import ( // // The sections are: // - the todos of a rebase, cherry-pick or revert -// - the commits of the branch that aren't merged into a main branch yet -func indexForGotoBottomInCommits(commits []*models.Commit, selectedIdx int) int { +// - 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 +func indexForGotoBottomInCommits( + commits []*models.Commit, selectedIdx int, branchesBelow []*models.Branch, +) int { 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 { diff --git a/pkg/gui/context/commit_list_goto_bottom_test.go b/pkg/gui/context/commit_list_goto_bottom_test.go index b37784814..bb942e480 100644 --- a/pkg/gui/context/commit_list_goto_bottom_test.go +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -34,11 +34,39 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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, + }) + } + 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), + } + scenarios := []struct { - testName string - selectedIdx int - commits []*models.Commit - expected int + testName string + selectedIdx int + commits []*models.Commit + branchesBelow []*models.Branch + expected int }{ { testName: "goes to the beginning of the branch first", @@ -111,11 +139,53 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { }, 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, + }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - assert.Equal(t, s.expected, indexForGotoBottomInCommits(s.commits, s.selectedIdx)) + assert.Equal(t, s.expected, indexForGotoBottomInCommits(s.commits, s.selectedIdx, s.branchesBelow)) }) } } diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index a00783147..23abc0239 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,5 +521,9 @@ func searchModelCommits(caseSensitive bool, commits []*models.Commit, columnPosi } func (self *LocalCommitsContext) IndexForGotoBottom() int { - return indexForGotoBottomInCommits(self.GetCommits(), self.GetSelectedLineIdx()) + c := self.ListContextTrait.c + commits := self.GetCommits() + branchesBelow := branchstack.BranchesBelow( + commits, c.Model().Branches, c.Model().CheckedOutBranch, c.UserConfig().Git.MainBranches) + return indexForGotoBottomInCommits(commits, self.GetSelectedLineIdx(), branchesBelow) } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index c6bad0f45..73adc4b89 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" @@ -269,5 +270,11 @@ func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitiv } func (self *SubCommitsContext) IndexForGotoBottom() int { - return indexForGotoBottomInCommits(self.GetCommits(), self.GetSelectedLineIdx()) + commits := self.GetCommits() + var branchesBelow []*models.Branch + if self.GetShowBranchHeads() { + branchesBelow = branchstack.BranchesBelow( + commits, self.c.Model().Branches, self.GetRef().RefName(), self.c.UserConfig().Git.MainBranches) + } + return indexForGotoBottomInCommits(commits, self.GetSelectedLineIdx(), branchesBelow) } 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..0219fb43e 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -156,6 +156,7 @@ var tests = []*components.IntegrationTest{ commit.FindBaseCommitForFixupDisregardMainBranch, commit.FindBaseCommitForFixupOnlyAddedLines, commit.FindBaseCommitForFixupWarningForAddedLines, + commit.GotoBottomInBranchStack, commit.Highlight, commit.History, commit.HistoryComplex, From 280c67ff9866968821356e5be7286b5a268563d0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:48:03 +0100 Subject: [PATCH 10/11] Make "go to bottom" stop at the bad, current and good commits of a bisect During a bisect, the commits panel shows the commits known to be bad at the top, the known good ones at the bottom, and the ones that the bisect hasn't ruled out yet between them. Pressing '>' now stops at the bottommost bad commit, then at the commit to test, and then at the topmost good commit. It doesn't stop at each commit marked good. The commits between two of them are all good, and they don't form a section of their own. During a bisect, it also doesn't stop at the beginnings of the other sections, such as the beginning of a branch. The commits panel doesn't color the commits by whether they are merged while bisecting, so these would look like arbitrary commits. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/context/commit_list_goto_bottom.go | 39 ++++++++++++++- .../context/commit_list_goto_bottom_test.go | 4 +- pkg/gui/context/local_commits_context.go | 2 +- pkg/gui/context/sub_commits_context.go | 3 +- pkg/integration/tests/bisect/goto_bottom.go | 47 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 pkg/integration/tests/bisect/goto_bottom.go diff --git a/pkg/gui/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go index 3d955f628..288d276d6 100644 --- a/pkg/gui/context/commit_list_goto_bottom.go +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -2,6 +2,7 @@ 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" @@ -18,9 +19,21 @@ import ( // - 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 +// +// 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, + 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 { @@ -51,6 +64,30 @@ func indexForGotoBottomInCommits( 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 { diff --git a/pkg/gui/context/commit_list_goto_bottom_test.go b/pkg/gui/context/commit_list_goto_bottom_test.go index bb942e480..36956b1e2 100644 --- a/pkg/gui/context/commit_list_goto_bottom_test.go +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -3,6 +3,7 @@ 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" @@ -185,7 +186,8 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - assert.Equal(t, s.expected, indexForGotoBottomInCommits(s.commits, s.selectedIdx, s.branchesBelow)) + 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 23abc0239..6d63c1efd 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -525,5 +525,5 @@ func (self *LocalCommitsContext) IndexForGotoBottom() int { commits := self.GetCommits() branchesBelow := branchstack.BranchesBelow( commits, c.Model().Branches, c.Model().CheckedOutBranch, c.UserConfig().Git.MainBranches) - return indexForGotoBottomInCommits(commits, self.GetSelectedLineIdx(), branchesBelow) + 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 73adc4b89..edada3e52 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -276,5 +276,6 @@ func (self *SubCommitsContext) IndexForGotoBottom() int { branchesBelow = branchstack.BranchesBelow( commits, self.c.Model().Branches, self.GetRef().RefName(), self.c.UserConfig().Git.MainBranches) } - return indexForGotoBottomInCommits(commits, self.GetSelectedLineIdx(), branchesBelow) + return indexForGotoBottomInCommits( + commits, self.GetSelectedLineIdx(), branchesBelow, git_commands.NewNullBisectInfo()) } 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/test_list.go b/pkg/integration/tests/test_list.go index 0219fb43e..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, From af0e6ab7f2aa2991bd9c7d78d24da6c160e24745 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 8 Nov 2026 12:48:31 +0100 Subject: [PATCH 11/11] Make "go to bottom" in a divergence view stop at the last remote commit The divergence view shows the remote commits in a section above the local ones. Pressing '>' now goes to the beginning of the remote section before it goes on to the bottom of the local one. Co-Authored-By: Claude Opus 5.5 --- pkg/gui/context/commit_list_goto_bottom.go | 7 ++++ .../context/commit_list_goto_bottom_test.go | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/pkg/gui/context/commit_list_goto_bottom.go b/pkg/gui/context/commit_list_goto_bottom.go index 288d276d6..92da10b17 100644 --- a/pkg/gui/context/commit_list_goto_bottom.go +++ b/pkg/gui/context/commit_list_goto_bottom.go @@ -19,6 +19,7 @@ import ( // - 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 @@ -61,6 +62,12 @@ func indexForGotoBottomInCommits( 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) } diff --git a/pkg/gui/context/commit_list_goto_bottom_test.go b/pkg/gui/context/commit_list_goto_bottom_test.go index 36956b1e2..b6b653b85 100644 --- a/pkg/gui/context/commit_list_goto_bottom_test.go +++ b/pkg/gui/context/commit_list_goto_bottom_test.go @@ -40,6 +40,9 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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} } @@ -62,6 +65,14 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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 @@ -182,6 +193,27 @@ func TestIndexForGotoBottomInCommits(t *testing.T) { 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 {