From 0b87c9e6101b8016acf50d1c8f6900538eb44a7e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 31 Oct 2026 12:17:43 +0100 Subject: [PATCH] Ask git which commits a bisect hasn't ruled out yet The commits view marks the commits that a bisect hasn't ruled out yet with a "?". To find them, it looks for the bad commit and the first good commit in the list, and marks the commits between them. If there's no good commit in the list, it marks none. This is the case while git tests the merge base of a good and a bad commit on different branches. It's also the case early in a long bisect, when the good commit is further down than the commits that we load. With merges, the commits between the two can also include some that the bisect has ruled out. Ask git instead, with "git rev-list --not ...", when refreshing the commits while git knows both a good and a bad commit. This takes about as long as the git log that loads the commits, or less, and it gets faster as the bisect goes on. Keep only the commits that the commits view has loaded, because git may list many more. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/bisect.go | 38 +++++++++++++++ pkg/commands/git_commands/bisect_info.go | 14 ++++++ pkg/gui/controllers/helpers/refresh_helper.go | 3 ++ pkg/gui/presentation/commits.go | 46 +++---------------- ...ark_candidates_while_testing_merge_base.go | 3 -- .../select_current_commit_in_long_range.go | 3 -- 6 files changed, 62 insertions(+), 45 deletions(-) diff --git a/pkg/commands/git_commands/bisect.go b/pkg/commands/git_commands/bisect.go index e554af117..96fed84ae 100644 --- a/pkg/commands/git_commands/bisect.go +++ b/pkg/commands/git_commands/bisect.go @@ -5,6 +5,10 @@ import ( "path/filepath" "strconv" "strings" + + "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/samber/lo" ) type BisectCommands struct { @@ -181,6 +185,40 @@ func (self *BisectCommands) IsDone() (bool, []string, error) { return done, candidates, nil } +// Asks git which of the given commits the bisect hasn't ruled out yet, and +// stores them in the bisect info. Git may list many more commits than these, +// so we don't keep the others. +func (self *BisectCommands) LoadCandidates(info *BisectInfo, commits []*models.Commit) error { + if !info.Bisecting() { + return nil + } + + oldHashes := lo.Keys(lo.PickBy(info.statusMap, func(_ string, status BisectStatus) bool { + return status == BisectStatusOld + })) + cmdArgs := NewGitCmd("rev-list"). + Arg(info.GetNewHash(), "--not"). + Arg(oldHashes...). + ToArgv() + + loadedHashes := set.NewFromSlice(lo.Map(commits, func(commit *models.Commit, _ int) string { + return commit.Hash() + })) + candidates := set.New[string]() + err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) { + if hash := strings.TrimSpace(line); loadedHashes.Includes(hash) { + candidates.Add(hash) + } + return false, nil + }) + if err != nil { + return err + } + + info.candidates = candidates + return nil +} + // tells us whether the 'start' ref that we'll be sent back to after we're done // bisecting is actually a descendant of our current bisect commit. If it's not, we need to // render the commits from the bad commit. diff --git a/pkg/commands/git_commands/bisect_info.go b/pkg/commands/git_commands/bisect_info.go index 7eb5cda59..c364b1fa4 100644 --- a/pkg/commands/git_commands/bisect_info.go +++ b/pkg/commands/git_commands/bisect_info.go @@ -2,6 +2,7 @@ package git_commands import ( "github.com/jesseduffield/generics/maps" + "github.com/jesseduffield/generics/set" "github.com/samber/lo" "github.com/sirupsen/logrus" ) @@ -34,6 +35,11 @@ type BisectInfo struct { // the hash of the commit that's under test current string + + // the commits that the bisect hasn't ruled out yet, among those that the + // commits view has loaded. Nil until BisectCommands.LoadCandidates is + // called. + candidates *set.Set[string] } type BisectStatus int @@ -78,6 +84,14 @@ func (self *BisectInfo) HasCommitToTest() bool { return !marked } +// Whether the bisect hasn't ruled out the commit yet. This is the case if the +// new commit can reach it, but no old one can, so it includes the new commit +// and the skipped ones. Only known for the commits that the commits view has +// loaded. +func (self *BisectInfo) IsCandidate(commitHash string) bool { + return self.candidates != nil && self.candidates.Includes(commitHash) +} + func (self *BisectInfo) GetStartHash() string { return self.start } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 20714788f..d1356c2aa 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -871,6 +871,9 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, if err != nil { return err } + if err := env.git.Bisect.LoadCandidates(bisectInfo, commits); err != nil { + self.c.Log.Warnf("Failed to find the commits that the bisect hasn't ruled out yet: %v", err) + } workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index d2036aa2e..28fc356bf 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -36,11 +36,6 @@ var ( pipeSetCacheAuthorColors = authors.ColorsVersion() ) -type bisectBounds struct { - newIndex int - oldIndex int -} - func GetCommitListDisplayStrings( common *common.Common, commits []*models.Commit, @@ -78,8 +73,6 @@ func GetCommitListDisplayStrings( filteredCommits := commits[startIdx:endIdx] - bisectBounds := getbisectBounds(commits, bisectInfo) - // function expects to be passed the index of the commit in terms of the `commits` slice var getGraphLine func(int) string if showGraph { @@ -187,14 +180,14 @@ func GetCommitListDisplayStrings( reservedWidths := getReservedColumnWidths( commits, common.UserConfig().Gui.CommitHashLength, fullDescription, - timeFormat, shortTimeFormat, now, bisectInfo, bisectBounds) + timeFormat, shortTimeFormat, now, bisectInfo) lines := make([][]string, 0, len(filteredCommits)) var bisectStatus BisectStatus willBeRebased := markedBaseCommit == "" for i, commit := range filteredCommits { unfilteredIdx := i + startIdx - bisectStatus = getBisectStatus(unfilteredIdx, commit.Hash(), bisectInfo, bisectBounds) + bisectStatus = getBisectStatus(commit.Hash(), bisectInfo) isMarkedBaseCommit := commit.Hash() != "" && commit.Hash() == markedBaseCommit if isMarkedBaseCommit { willBeRebased = true @@ -243,16 +236,15 @@ func getReservedColumnWidths( shortTimeFormat string, now time.Time, bisectInfo *git_commands.BisectInfo, - bisectBounds *bisectBounds, ) reservedColumnWidths { result := reservedColumnWidths{} - for i, commit := range commits { + for _, commit := range commits { result.hash = max(result.hash, utils.StringWidth(getHashText(commit, hashLength))) if commit.IsTODO() { result.action = max(result.action, utils.StringWidth(getActionText(commit))) } - bisectStatus := getBisectStatus(i, commit.Hash(), bisectInfo, bisectBounds) + bisectStatus := getBisectStatus(commit.Hash(), bisectInfo) result.bisect = max(result.bisect, utils.StringWidth(getBisectStatusText(bisectStatus, bisectInfo))) } @@ -274,29 +266,6 @@ func getReservedColumnWidths( return result } -func getbisectBounds(commits []*models.Commit, bisectInfo *git_commands.BisectInfo) *bisectBounds { - if !bisectInfo.Bisecting() { - return nil - } - - bisectBounds := &bisectBounds{} - - for i, commit := range commits { - if commit.Hash() == bisectInfo.GetNewHash() { - bisectBounds.newIndex = i - } - - status, ok := bisectInfo.Status(commit.Hash()) - if ok && status == git_commands.BisectStatusOld { - bisectBounds.oldIndex = i - return bisectBounds - } - } - - // shouldn't land here - return nil -} - // precondition: slice is not empty func indexOfFirstNonTODOCommit(commits []*models.Commit) int { for i, commit := range commits { @@ -345,14 +314,13 @@ const ( BisectStatusOld BisectStatusNew BisectStatusSkipped - // adding candidate here which isn't present in the commands package because - // we need to actually go through the commits to get this info + // a commit that has no mark, and that the bisect hasn't ruled out yet BisectStatusCandidate // also adding this BisectStatusCurrent ) -func getBisectStatus(index int, commitHash string, bisectInfo *git_commands.BisectInfo, bisectBounds *bisectBounds) BisectStatus { +func getBisectStatus(commitHash string, bisectInfo *git_commands.BisectInfo) BisectStatus { if !bisectInfo.Started() { return BisectStatusNone } @@ -372,7 +340,7 @@ func getBisectStatus(index int, commitHash string, bisectInfo *git_commands.Bise return BisectStatusSkipped } } else { - if bisectBounds != nil && index >= bisectBounds.newIndex && index <= bisectBounds.oldIndex { + if bisectInfo.IsCandidate(commitHash) { return BisectStatusCandidate } return BisectStatusNone diff --git a/pkg/integration/tests/bisect/mark_candidates_while_testing_merge_base.go b/pkg/integration/tests/bisect/mark_candidates_while_testing_merge_base.go index 7e8994dc6..a9aa717eb 100644 --- a/pkg/integration/tests/bisect/mark_candidates_while_testing_merge_base.go +++ b/pkg/integration/tests/bisect/mark_candidates_while_testing_merge_base.go @@ -28,10 +28,7 @@ var MarkCandidatesWhileTestingMergeBase = NewIntegrationTest(NewIntegrationTestA IsFocused(). Lines( Contains("CI commit-05").Contains("<-- bad"), - /* EXPECTED: MatchesRegexp(`\?.*commit-04`), - ACTUAL: */ - Contains("CI commit-04").DoesNotContain("?"), Contains("CI commit-02").Contains("<-- current").IsSelected(), // This is an ancestor of the good commit-03, so the bisect has // ruled it out diff --git a/pkg/integration/tests/bisect/select_current_commit_in_long_range.go b/pkg/integration/tests/bisect/select_current_commit_in_long_range.go index 5f0caa183..6a733ed49 100644 --- a/pkg/integration/tests/bisect/select_current_commit_in_long_range.go +++ b/pkg/integration/tests/bisect/select_current_commit_in_long_range.go @@ -28,9 +28,6 @@ var SelectCurrentCommitInLongRange = NewIntegrationTest(NewIntegrationTestArgs{ // to be tested. The good one is further down than the commits that // we load. SelectNextItem(). - /* EXPECTED: SelectedLine(Contains("?")) - ACTUAL: */ - SelectedLine(DoesNotContain("?")) }, })