mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-07-30 03:23:08 +03:00
add support for git bisect
This commit is contained in:
@ -36,6 +36,7 @@ type GitCommand struct {
|
||||
Sync *git_commands.SyncCommands
|
||||
Tag *git_commands.TagCommands
|
||||
WorkingTree *git_commands.WorkingTreeCommands
|
||||
Bisect *git_commands.BisectCommands
|
||||
|
||||
Loaders Loaders
|
||||
}
|
||||
@ -113,6 +114,7 @@ func NewGitCommandAux(
|
||||
// TODO: have patch manager take workingTreeCommands in its entirety
|
||||
patchManager := patch.NewPatchManager(cmn.Log, workingTreeCommands.ApplyPatch, workingTreeCommands.ShowFileDiff)
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchManager)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
return &GitCommand{
|
||||
Branch: branchCommands,
|
||||
@ -129,6 +131,7 @@ func NewGitCommandAux(
|
||||
Submodule: submoduleCommands,
|
||||
Sync: syncCommands,
|
||||
Tag: tagCommands,
|
||||
Bisect: bisectCommands,
|
||||
WorkingTree: workingTreeCommands,
|
||||
Loaders: Loaders{
|
||||
Branches: loaders.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchName, configCommands),
|
||||
|
164
pkg/commands/git_commands/bisect.go
Normal file
164
pkg/commands/git_commands/bisect.go
Normal file
@ -0,0 +1,164 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BisectCommands struct {
|
||||
*GitCommon
|
||||
}
|
||||
|
||||
func NewBisectCommands(gitCommon *GitCommon) *BisectCommands {
|
||||
return &BisectCommands{
|
||||
GitCommon: gitCommon,
|
||||
}
|
||||
}
|
||||
|
||||
// This command is pretty cheap to run so we're not storing the result anywhere.
|
||||
// But if it becomes problematic we can chang that.
|
||||
func (self *BisectCommands) GetInfo() *BisectInfo {
|
||||
var err error
|
||||
info := &BisectInfo{started: false, log: self.Log, newTerm: "bad", oldTerm: "good"}
|
||||
// we return nil if we're not in a git bisect session.
|
||||
// we know we're in a session by the presence of a .git/BISECT_START file
|
||||
|
||||
bisectStartPath := filepath.Join(self.dotGitDir, "BISECT_START")
|
||||
exists, err := self.os.FileExists(bisectStartPath)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return info
|
||||
}
|
||||
|
||||
startContent, err := os.ReadFile(bisectStartPath)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
info.started = true
|
||||
info.start = strings.TrimSpace(string(startContent))
|
||||
|
||||
termsContent, err := os.ReadFile(filepath.Join(self.dotGitDir, "BISECT_TERMS"))
|
||||
if err != nil {
|
||||
// old git versions won't have this file so we default to bad/good
|
||||
} else {
|
||||
splitContent := strings.Split(string(termsContent), "\n")
|
||||
info.newTerm = splitContent[0]
|
||||
info.oldTerm = splitContent[1]
|
||||
}
|
||||
|
||||
bisectRefsDir := filepath.Join(self.dotGitDir, "refs", "bisect")
|
||||
files, err := os.ReadDir(bisectRefsDir)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
info.statusMap = make(map[string]BisectStatus)
|
||||
for _, file := range files {
|
||||
status := BisectStatusSkipped
|
||||
name := file.Name()
|
||||
path := filepath.Join(bisectRefsDir, name)
|
||||
|
||||
fileContent, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
|
||||
sha := strings.TrimSpace(string(fileContent))
|
||||
|
||||
if name == info.newTerm {
|
||||
status = BisectStatusNew
|
||||
} else if strings.HasPrefix(name, info.oldTerm+"-") {
|
||||
status = BisectStatusOld
|
||||
} else if strings.HasPrefix(name, "skipped-") {
|
||||
status = BisectStatusSkipped
|
||||
}
|
||||
|
||||
info.statusMap[sha] = status
|
||||
}
|
||||
|
||||
currentContent, err := os.ReadFile(filepath.Join(self.dotGitDir, "BISECT_EXPECTED_REV"))
|
||||
if err != nil {
|
||||
self.Log.Infof("error getting git bisect info: %s", err.Error())
|
||||
return info
|
||||
}
|
||||
currentSha := strings.TrimSpace(string(currentContent))
|
||||
info.current = currentSha
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Reset() error {
|
||||
return self.cmd.New("git bisect reset").StreamOutput().Run()
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Mark(ref string, term string) error {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git bisect %s %s", term, ref),
|
||||
).
|
||||
IgnoreEmptyError().
|
||||
StreamOutput().
|
||||
Run()
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Skip(ref string) error {
|
||||
return self.Mark(ref, "skip")
|
||||
}
|
||||
|
||||
func (self *BisectCommands) Start() error {
|
||||
return self.cmd.New("git bisect start").StreamOutput().Run()
|
||||
}
|
||||
|
||||
// tells us whether we've found our problem commit(s). We return a string slice of
|
||||
// commit sha's if we're done, and that slice may have more that one item if
|
||||
// skipped commits are involved.
|
||||
func (self *BisectCommands) IsDone() (bool, []string, error) {
|
||||
info := self.GetInfo()
|
||||
if !info.Bisecting() {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
newSha := info.GetNewSha()
|
||||
if newSha == "" {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// if we start from the new commit and reach the a good commit without
|
||||
// coming across any unprocessed commits, then we're done
|
||||
done := false
|
||||
candidates := []string{}
|
||||
|
||||
err := self.cmd.New(fmt.Sprintf("git rev-list %s", newSha)).RunAndProcessLines(func(line string) (bool, error) {
|
||||
sha := strings.TrimSpace(line)
|
||||
|
||||
if status, ok := info.statusMap[sha]; ok {
|
||||
switch status {
|
||||
case BisectStatusSkipped, BisectStatusNew:
|
||||
candidates = append(candidates, sha)
|
||||
return false, nil
|
||||
case BisectStatusOld:
|
||||
done = true
|
||||
return true, nil
|
||||
}
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// should never land here
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return done, candidates, nil
|
||||
}
|
103
pkg/commands/git_commands/bisect_info.go
Normal file
103
pkg/commands/git_commands/bisect_info.go
Normal file
@ -0,0 +1,103 @@
|
||||
package git_commands
|
||||
|
||||
import "github.com/sirupsen/logrus"
|
||||
|
||||
// although the typical terms in a git bisect are 'bad' and 'good', they're more
|
||||
// generally known as 'new' and 'old'. Semi-recently git allowed the user to define
|
||||
// their own terms e.g. when you want to used 'fixed', 'unfixed' in the event
|
||||
// that you're looking for a commit that fixed a bug.
|
||||
|
||||
// Git bisect only keeps track of a single 'bad' commit. Once you pick a commit
|
||||
// that's older than the current bad one, it forgets about the previous one. On
|
||||
// the other hand, it does keep track of all the good and skipped commits.
|
||||
|
||||
type BisectInfo struct {
|
||||
log *logrus.Entry
|
||||
|
||||
// tells us whether all our git bisect files are there meaning we're in bisect mode.
|
||||
// Doesn't necessarily mean that we've actually picked a good/bad commit yet.
|
||||
started bool
|
||||
|
||||
// this is the ref you started the commit from
|
||||
start string // this will always be defined
|
||||
|
||||
// these will be defined if we've started
|
||||
newTerm string // 'bad' by default
|
||||
oldTerm string // 'good' by default
|
||||
|
||||
// map of commit sha's to their status
|
||||
statusMap map[string]BisectStatus
|
||||
|
||||
// the sha of the commit that's under test
|
||||
current string
|
||||
}
|
||||
|
||||
type BisectStatus int
|
||||
|
||||
const (
|
||||
BisectStatusOld BisectStatus = iota
|
||||
BisectStatusNew
|
||||
BisectStatusSkipped
|
||||
)
|
||||
|
||||
// null object pattern
|
||||
func NewNullBisectInfo() *BisectInfo {
|
||||
return &BisectInfo{started: false}
|
||||
}
|
||||
|
||||
func (self *BisectInfo) GetNewSha() string {
|
||||
for sha, status := range self.statusMap {
|
||||
if status == BisectStatusNew {
|
||||
return sha
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *BisectInfo) GetCurrentSha() string {
|
||||
return self.current
|
||||
}
|
||||
|
||||
func (self *BisectInfo) StartSha() string {
|
||||
return self.start
|
||||
}
|
||||
|
||||
func (self *BisectInfo) Status(commitSha string) (BisectStatus, bool) {
|
||||
status, ok := self.statusMap[commitSha]
|
||||
return status, ok
|
||||
}
|
||||
|
||||
func (self *BisectInfo) NewTerm() string {
|
||||
return self.newTerm
|
||||
}
|
||||
|
||||
func (self *BisectInfo) OldTerm() string {
|
||||
return self.oldTerm
|
||||
}
|
||||
|
||||
// this is for when we have called `git bisect start`. It does not
|
||||
// mean that we have actually started narrowing things down or selecting good/bad commits
|
||||
func (self *BisectInfo) Started() bool {
|
||||
return self.started
|
||||
}
|
||||
|
||||
// this is where we have both a good and bad revision and we're actually
|
||||
// starting to narrow things down
|
||||
func (self *BisectInfo) Bisecting() bool {
|
||||
if !self.Started() {
|
||||
return false
|
||||
}
|
||||
|
||||
if self.GetNewSha() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, status := range self.statusMap {
|
||||
if status == BisectStatusOld {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
@ -75,7 +75,19 @@ func (self *CommitCommands) GetCommitMessage(commitSha string) (string, error) {
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitMessageFirstLine(sha string) (string, error) {
|
||||
return self.cmd.New(fmt.Sprintf("git show --no-patch --pretty=format:%%s %s", sha)).DontLog().RunWithOutput()
|
||||
return self.GetCommitMessagesFirstLine([]string{sha})
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitMessagesFirstLine(shas []string) (string, error) {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git show --no-patch --pretty=format:%%s %s", strings.Join(shas, " ")),
|
||||
).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *CommitCommands) GetCommitsOneline(shas []string) (string, error) {
|
||||
return self.cmd.New(
|
||||
fmt.Sprintf("git show --no-patch --oneline %s", strings.Join(shas, " ")),
|
||||
).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
// AmendHead amends HEAD with whatever is staged in your working tree
|
||||
|
@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
// Commit : A git commit
|
||||
type Commit struct {
|
||||
@ -18,10 +22,7 @@ type Commit struct {
|
||||
}
|
||||
|
||||
func (c *Commit) ShortSha() string {
|
||||
if len(c.Sha) < 8 {
|
||||
return c.Sha
|
||||
}
|
||||
return c.Sha[:8]
|
||||
return utils.ShortSha(c.Sha)
|
||||
}
|
||||
|
||||
func (c *Commit) RefName() string {
|
||||
|
@ -35,6 +35,18 @@ type ICmdObj interface {
|
||||
// This returns false if DontLog() was called
|
||||
ShouldLog() bool
|
||||
|
||||
// when you call this, then call Run(), we'll stream the output to the cmdWriter (i.e. the command log panel)
|
||||
StreamOutput() ICmdObj
|
||||
// returns true if StreamOutput() was called
|
||||
ShouldStreamOutput() bool
|
||||
|
||||
// if you call this before ShouldStreamOutput we'll consider an error with no
|
||||
// stderr content as a non-error. Not yet supported for Run or RunWithOutput (
|
||||
// but adding support is trivial)
|
||||
IgnoreEmptyError() ICmdObj
|
||||
// returns true if IgnoreEmptyError() was called
|
||||
ShouldIgnoreEmptyError() bool
|
||||
|
||||
PromptOnCredentialRequest() ICmdObj
|
||||
FailOnCredentialRequest() ICmdObj
|
||||
|
||||
@ -47,9 +59,15 @@ type CmdObj struct {
|
||||
|
||||
runner ICmdObjRunner
|
||||
|
||||
// if set to true, we don't want to log the command to the user.
|
||||
// see DontLog()
|
||||
dontLog bool
|
||||
|
||||
// see StreamOutput()
|
||||
streamOutput bool
|
||||
|
||||
// see IgnoreEmptyError()
|
||||
ignoreEmptyError bool
|
||||
|
||||
// if set to true, it means we might be asked to enter a username/password by this command.
|
||||
credentialStrategy CredentialStrategy
|
||||
}
|
||||
@ -98,6 +116,26 @@ func (self *CmdObj) ShouldLog() bool {
|
||||
return !self.dontLog
|
||||
}
|
||||
|
||||
func (self *CmdObj) StreamOutput() ICmdObj {
|
||||
self.streamOutput = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CmdObj) ShouldStreamOutput() bool {
|
||||
return self.streamOutput
|
||||
}
|
||||
|
||||
func (self *CmdObj) IgnoreEmptyError() ICmdObj {
|
||||
self.ignoreEmptyError = true
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *CmdObj) ShouldIgnoreEmptyError() bool {
|
||||
return self.ignoreEmptyError
|
||||
}
|
||||
|
||||
func (self *CmdObj) Run() error {
|
||||
return self.runner.Run(self)
|
||||
}
|
||||
|
@ -34,15 +34,27 @@ type cmdObjRunner struct {
|
||||
var _ ICmdObjRunner = &cmdObjRunner{}
|
||||
|
||||
func (self *cmdObjRunner) Run(cmdObj ICmdObj) error {
|
||||
if cmdObj.GetCredentialStrategy() == NONE {
|
||||
_, err := self.RunWithOutput(cmdObj)
|
||||
return err
|
||||
} else {
|
||||
if cmdObj.GetCredentialStrategy() != NONE {
|
||||
return self.runWithCredentialHandling(cmdObj)
|
||||
}
|
||||
|
||||
if cmdObj.ShouldStreamOutput() {
|
||||
return self.runAndStream(cmdObj)
|
||||
}
|
||||
|
||||
_, err := self.RunWithOutput(cmdObj)
|
||||
return err
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
|
||||
if cmdObj.ShouldStreamOutput() {
|
||||
err := self.runAndStream(cmdObj)
|
||||
// for now we're not capturing output, just because it would take a little more
|
||||
// effort and there's currently no use case for it. Some commands call RunWithOutput
|
||||
// but ignore the output, hence why we've got this check here.
|
||||
return "", err
|
||||
}
|
||||
|
||||
if cmdObj.GetCredentialStrategy() != NONE {
|
||||
err := self.runWithCredentialHandling(cmdObj)
|
||||
// for now we're not capturing output, just because it would take a little more
|
||||
@ -145,12 +157,36 @@ type cmdHandler struct {
|
||||
close func() error
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) runAndStream(cmdObj ICmdObj) error {
|
||||
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
||||
go func() {
|
||||
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// runAndDetectCredentialRequest detect a username / password / passphrase question in a command
|
||||
// promptUserForCredential is a function that gets executed when this function detect you need to fillin a password or passphrase
|
||||
// The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back
|
||||
func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
cmdObj ICmdObj,
|
||||
promptUserForCredential func(CredentialType) string,
|
||||
) error {
|
||||
// setting the output to english so we can parse it for a username/password request
|
||||
cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8")
|
||||
|
||||
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
|
||||
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
|
||||
|
||||
go utils.Safe(func() {
|
||||
self.processOutput(tr, handler.stdinPipe, promptUserForCredential)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (self *cmdObjRunner) runAndStreamAux(
|
||||
cmdObj ICmdObj,
|
||||
onRun func(*cmdHandler, io.Writer),
|
||||
) error {
|
||||
cmdWriter := self.guiIO.newCmdWriterFn()
|
||||
|
||||
@ -158,7 +194,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
self.logCmdObj(cmdObj)
|
||||
}
|
||||
self.log.WithField("command", cmdObj.ToString()).Info("RunCommand")
|
||||
cmd := cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8").GetCmd()
|
||||
cmd := cmdObj.GetCmd()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = io.MultiWriter(cmdWriter, &stderr)
|
||||
@ -174,14 +210,14 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
|
||||
}
|
||||
}()
|
||||
|
||||
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
|
||||
|
||||
go utils.Safe(func() {
|
||||
self.processOutput(tr, handler.stdinPipe, promptUserForCredential)
|
||||
})
|
||||
onRun(handler, cmdWriter)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
errStr := stderr.String()
|
||||
if cmdObj.ShouldIgnoreEmptyError() && errStr == "" {
|
||||
return nil
|
||||
}
|
||||
return errors.New(stderr.String())
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user