diff options
author | Jesse Duffield <jessedduffield@gmail.com> | 2022-01-19 18:32:27 +1100 |
---|---|---|
committer | Jesse Duffield <jessedduffield@gmail.com> | 2022-01-22 10:48:51 +1100 |
commit | 4ab5e5413944a92699a9540148666f0de26aa44b (patch) | |
tree | 58a4b648b5a4e5a6ed5f01b4e40ae5ba3c3ebad7 /pkg/commands | |
parent | ab84410b4155bcee73ead0e2a7510924575b1323 (diff) |
add support for git bisect
Diffstat (limited to 'pkg/commands')
-rw-r--r-- | pkg/commands/git.go | 3 | ||||
-rw-r--r-- | pkg/commands/git_commands/bisect.go | 164 | ||||
-rw-r--r-- | pkg/commands/git_commands/bisect_info.go | 103 | ||||
-rw-r--r-- | pkg/commands/git_commands/commit.go | 14 | ||||
-rw-r--r-- | pkg/commands/models/commit.go | 11 | ||||
-rw-r--r-- | pkg/commands/oscommands/cmd_obj.go | 40 | ||||
-rw-r--r-- | pkg/commands/oscommands/cmd_obj_runner.go | 56 |
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()) } |