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 <bad> --not <good>...", 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 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-31 12:17:43 +01:00
co-authored by Claude Opus 5.5
parent fd7178a3a9
commit 0b87c9e610
6 changed files with 62 additions and 45 deletions
+38
View File
@@ -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.
+14
View File
@@ -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
}
@@ -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() {
+7 -39
View File
@@ -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
@@ -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
@@ -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("?"))
},
})