summaryrefslogtreecommitdiffstats
path: root/pkg
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
parentab84410b4155bcee73ead0e2a7510924575b1323 (diff)
add support for git bisect
Diffstat (limited to 'pkg')
-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
-rw-r--r--pkg/config/user_config.go3
-rw-r--r--pkg/gui/bisect.go204
-rw-r--r--pkg/gui/commits_panel.go9
-rw-r--r--pkg/gui/custom_commands.go6
-rw-r--r--pkg/gui/filetree/commit_file_manager.go3
-rw-r--r--pkg/gui/filetree/file_manager.go3
-rw-r--r--pkg/gui/filetree/presentation.go (renamed from pkg/gui/presentation/files.go)47
-rw-r--r--pkg/gui/gui.go3
-rw-r--r--pkg/gui/keybindings.go8
-rw-r--r--pkg/gui/list_context_config.go3
-rw-r--r--pkg/gui/modes.go65
-rw-r--r--pkg/gui/presentation/commit_files.go49
-rw-r--r--pkg/gui/presentation/commits.go132
-rw-r--r--pkg/gui/view_helpers.go6
-rw-r--r--pkg/i18n/english.go38
-rw-r--r--pkg/utils/formatting.go7
23 files changed, 874 insertions, 103 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())
}
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 8c1d90a0e..2b79f941f 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -247,6 +247,7 @@ type KeybindingCommitsConfig struct {
CopyCommitMessageToClipboard string `yaml:"copyCommitMessageToClipboard"`
OpenLogMenu string `yaml:"openLogMenu"`
OpenInBrowser string `yaml:"openInBrowser"`
+ ViewBisectOptions string `yaml:"viewBisectOptions"`
}
type KeybindingStashConfig struct {
@@ -293,6 +294,7 @@ type CustomCommand struct {
Prompts []CustomCommandPrompt `yaml:"prompts"`
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
+ Stream bool `yaml:"stream"`
}
type CustomCommandPrompt struct {
@@ -510,6 +512,7 @@ func GetDefaultConfig() *UserConfig {
CopyCommitMessageToClipboard: "<c-y>",
OpenLogMenu: "<c-l>",
OpenInBrowser: "o",
+ ViewBisectOptions: "b",
},
Stash: KeybindingStashConfig{
PopStash: "g",
diff --git a/pkg/gui/bisect.go b/pkg/gui/bisect.go
new file mode 100644
index 000000000..ca00ce87f
--- /dev/null
+++ b/pkg/gui/bisect.go
@@ -0,0 +1,204 @@
+package gui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+)
+
+func (gui *Gui) handleOpenBisectMenu() error {
+ if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
+ return err
+ }
+
+ // no shame in getting this directly rather than using the cached value
+ // given how cheap it is to obtain
+ info := gui.Git.Bisect.GetInfo()
+ commit := gui.getSelectedLocalCommit()
+ if info.Started() {
+ return gui.openMidBisectMenu(info, commit)
+ } else {
+ return gui.openStartBisectMenu(info, commit)
+ }
+}
+
+func (gui *Gui) openMidBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
+ // if there is not yet a 'current' bisect commit, or if we have
+ // selected the current commit, we need to jump to the next 'current' commit
+ // after we perform a bisect action. The reason we don't unconditionally jump
+ // is that sometimes the user will want to go and mark a few commits as skipped
+ // in a row and they wouldn't want to be jumped back to the current bisect
+ // commit each time.
+ // Originally we were allowing the user to, from the bisect menu, select whether
+ // they were talking about the selected commit or the current bisect commit,
+ // and that was a bit confusing (and required extra keypresses).
+ selectCurrentAfter := info.GetCurrentSha() == "" || info.GetCurrentSha() == commit.Sha
+
+ menuItems := []*menuItem{
+ {
+ displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.NewTerm()),
+ onPress: func() error {
+ gui.logAction(gui.Tr.Actions.BisectMark)
+ if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.afterMark(selectCurrentAfter)
+ },
+ },
+ {
+ displayString: fmt.Sprintf(gui.Tr.Bisect.Mark, commit.ShortSha(), info.OldTerm()),
+ onPress: func() error {
+ gui.logAction(gui.Tr.Actions.BisectMark)
+ if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.afterMark(selectCurrentAfter)
+ },
+ },
+ {
+ displayString: fmt.Sprintf(gui.Tr.Bisect.Skip, commit.ShortSha()),
+ onPress: func() error {
+ gui.logAction(gui.Tr.Actions.BisectSkip)
+ if err := gui.Git.Bisect.Skip(commit.Sha); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.afterMark(selectCurrentAfter)
+ },
+ },
+ {
+ displayString: gui.Tr.Bisect.ResetOption,
+ onPress: func() error {
+ return gui.resetBisect()
+ },
+ },
+ }
+
+ return gui.createMenu(
+ gui.Tr.Bisect.BisectMenuTitle,
+ menuItems,
+ createMenuOptions{showCancel: true},
+ )
+}
+
+func (gui *Gui) openStartBisectMenu(info *git_commands.BisectInfo, commit *models.Commit) error {
+ return gui.createMenu(
+ gui.Tr.Bisect.BisectMenuTitle,
+ []*menuItem{
+ {
+ displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.NewTerm()),
+ onPress: func() error {
+ gui.logAction(gui.Tr.Actions.StartBisect)
+ if err := gui.Git.Bisect.Start(); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ if err := gui.Git.Bisect.Mark(commit.Sha, info.NewTerm()); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.postBisectCommandRefresh()
+ },
+ },
+ {
+ displayString: fmt.Sprintf(gui.Tr.Bisect.MarkStart, commit.ShortSha(), info.OldTerm()),
+ onPress: func() error {
+ gui.logAction(gui.Tr.Actions.StartBisect)
+ if err := gui.Git.Bisect.Start(); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ if err := gui.Git.Bisect.Mark(commit.Sha, info.OldTerm()); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.postBisectCommandRefresh()
+ },
+ },
+ },
+ createMenuOptions{showCancel: true},
+ )
+}
+
+func (gui *Gui) resetBisect() error {
+ return gui.ask(askOpts{
+ title: gui.Tr.Bisect.ResetTitle,
+ prompt: gui.Tr.Bisect.ResetPrompt,
+ handleConfirm: func() error {
+ gui.logAction(gui.Tr.Actions.ResetBisect)
+ if err := gui.Git.Bisect.Reset(); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.postBisectCommandRefresh()
+ },
+ })
+}
+
+func (gui *Gui) showBisectCompleteMessage(candidateShas []string) error {
+ prompt := gui.Tr.Bisect.CompletePrompt
+ if len(candidateShas) > 1 {
+ prompt = gui.Tr.Bisect.CompletePromptIndeterminate
+ }
+
+ formattedCommits, err := gui.Git.Commit.GetCommitsOneline(candidateShas)
+ if err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.ask(askOpts{
+ title: gui.Tr.Bisect.CompleteTitle,
+ prompt: fmt.Sprintf(prompt, strings.TrimSpace(formattedCommits)),
+ handleConfirm: func() error {
+ gui.logAction(gui.Tr.Actions.ResetBisect)
+ if err := gui.Git.Bisect.Reset(); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ return gui.postBisectCommandRefresh()
+ },
+ })
+}
+
+func (gui *Gui) afterMark(selectCurrent bool) error {
+ done, candidateShas, err := gui.Git.Bisect.IsDone()
+ if err != nil {
+ return gui.surfaceError(err)
+ }
+
+ if err := gui.postBisectCommandRefresh(); err != nil {
+ return gui.surfaceError(err)
+ }
+
+ if done {
+ return gui.showBisectCompleteMessage(candidateShas)
+ }
+
+ if selectCurrent {
+ gui.selectCurrentBisectCommit()
+ }
+
+ return nil
+}
+
+func (gui *Gui) selectCurrentBisectCommit() {
+ info := gui.Git.Bisect.GetInfo()
+ if info.GetCurrentSha() != "" {
+ // find index of commit with that sha, move cursor to that.
+ for i, commit := range gui.State.Commits {
+ if commit.Sha == info.GetCurrentSha() {
+ gui.State.Contexts.BranchCommits.GetPanelState().SetSelectedLineIdx(i)
+ _ = gui.State.Contexts.BranchCommits.HandleFocus()
+ break
+ }
+ }
+ }
+}
+
+func (gui *Gui) postBisectCommandRefresh() error {
+ return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{}})
+}
diff --git a/pkg/gui/commits_panel.go b/pkg/gui/commits_panel.go
index 0f63e4157..d568caf88 100644
--- a/pkg/gui/commits_panel.go
+++ b/pkg/gui/commits_panel.go
@@ -116,12 +116,19 @@ func (gui *Gui) refreshCommitsWithLimit() error {
gui.Mutexes.BranchCommitsMutex.Lock()
defer gui.Mutexes.BranchCommitsMutex.Unlock()
+ refName := "HEAD"
+ bisectInfo := gui.Git.Bisect.GetInfo()
+ gui.State.BisectInfo = bisectInfo
+ if bisectInfo.Started() {
+ refName = bisectInfo.StartSha()
+ }
+
commits, err := gui.Git.Loaders.Commits.GetCommits(
loaders.GetCommitsOptions{
Limit: gui.State.Panels.Commits.LimitCommits,
FilterPath: gui.State.Modes.Filtering.GetPath(),
IncludeRebaseCommits: true,
- RefName: "HEAD",
+ RefName: refName,
All: gui.State.ShowWholeGitGraph,
},
)
diff --git a/pkg/gui/custom_commands.go b/pkg/gui/custom_commands.go
index c55b58a02..02293dd65 100644
--- a/pkg/gui/custom_commands.go
+++ b/pkg/gui/custom_commands.go
@@ -254,7 +254,11 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
}
return gui.WithWaitingStatus(loadingText, func() error {
gui.logAction(gui.Tr.Actions.CustomCommand)
- err := gui.OSCommand.Cmd.NewShell(cmdStr).Run()
+ cmdObj := gui.OSCommand.Cmd.NewShell(cmdStr)
+ if customCommand.Stream {
+ cmdObj.StreamOutput()
+ }
+ err := cmdObj.Run()
if err != nil {
return gui.surfaceError(err)
}
diff --git a/pkg/gui/filetree/commit_file_manager.go b/pkg/gui/filetree/commit_file_manager.g