summaryrefslogtreecommitdiffstats
path: root/pkg/commands
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2022-01-19 18:32:27 +1100
committerJesse Duffield <jessedduffield@gmail.com>2022-01-22 10:48:51 +1100
commit4ab5e5413944a92699a9540148666f0de26aa44b (patch)
tree58a4b648b5a4e5a6ed5f01b4e40ae5ba3c3ebad7 /pkg/commands
parentab84410b4155bcee73ead0e2a7510924575b1323 (diff)
add support for git bisect
Diffstat (limited to 'pkg/commands')
-rw-r--r--pkg/commands/git.go3
-rw-r--r--pkg/commands/git_commands/bisect.go164
-rw-r--r--pkg/commands/git_commands/bisect_info.go103
-rw-r--r--pkg/commands/git_commands/commit.go14
-rw-r--r--pkg/commands/models/commit.go11
-rw-r--r--pkg/commands/oscommands/cmd_obj.go40
-rw-r--r--pkg/commands/oscommands/cmd_obj_runner.go56
7 files changed, 374 insertions, 17 deletions
diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index b75410322..f6812e254 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -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),
diff --git a/pkg/commands/git_commands/bisect.go b/pkg/commands/git_commands/bisect.go
new file mode 100644
index 000000000..2e362af2a
--- /dev/null
+++ b/pkg/commands/git_commands/bisect.go
@@ -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
+}
diff --git a/pkg/commands/git_commands/bisect_info.go b/pkg/commands/git_commands/bisect_info.go
new file mode 100644
index 000000000..9b812a17f
--- /dev/null
+++ b/pkg/commands/git_commands/bisect_info.go
@@ -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
+}
diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go
index 2858c100a..38f50bdda 100644
--- a/pkg/commands/git_commands/commit.go
+++ b/pkg/commands/git_commands/commit.go
@@ -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
diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go
index 1b8659e77..330c525dc 100644
--- a/pkg/commands/models/commit.go
+++ b/pkg/commands/models/commit.go
@@ -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 {
diff --git a/pkg/commands/oscommands/cmd_obj.go b/pkg/commands/oscommands/cmd_obj.go
index bc1e9dc74..3e55359de 100644
--- a/pkg/commands/oscommands/cmd_obj.go
+++ b/pkg/commands/oscommands/cmd_obj.go
@@ -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)
}
diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go
index 37f46e76d..e1a38d80f 100644
--- a/pkg/commands/oscommands/cmd_obj_runner.go
+++ b/pkg/commands/oscommands/cmd_obj_runner.go
@@ -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,6 +157,14 @@ 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
@@ -152,13 +172,29 @@ 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()
if cmdObj.ShouldLog() {
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())
}