This commit is contained in:
Jesse Duffield
2021-12-30 10:41:15 +11:00
parent 26c07b1ab3
commit 65f910ebd8
11 changed files with 286 additions and 175 deletions

View File

@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
@@ -42,6 +41,8 @@ type GitCommand struct {
// Coincidentally at the moment it's the same view that OnRunCommand logs to
// but that need not always be the case.
GetCmdWriter func() io.Writer
Cmd oscommands.ICmdObjBuilder
}
// NewGitCommand it runs git commands
@@ -68,6 +69,8 @@ func NewGitCommand(
return nil, err
}
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd)
gitCommand := &GitCommand{
Common: cmn,
OSCommand: osCommand,
@@ -76,6 +79,7 @@ func NewGitCommand(
PushToCurrent: pushToCurrent,
GitConfig: gitConfig,
GetCmdWriter: func() io.Writer { return ioutil.Discard },
Cmd: cmd,
}
gitCommand.PatchManager = patch.NewPatchManager(gitCommand.Log, gitCommand.ApplyPatch, gitCommand.ShowFileDiff)
@@ -219,46 +223,19 @@ func VerifyInGitRepo(osCommand *oscommands.OSCommand) error {
}
func (c *GitCommand) Run(cmdObj oscommands.ICmdObj) error {
_, err := c.RunWithOutput(cmdObj)
return err
return cmdObj.Run()
}
func (c *GitCommand) RunWithOutput(cmdObj oscommands.ICmdObj) (string, error) {
// TODO: have this retry logic in other places we run the command
waitTime := 50 * time.Millisecond
retryCount := 5
attempt := 0
for {
output, err := c.OSCommand.RunWithOutput(cmdObj)
if err != nil {
// if we have an error based on the index lock, we should wait a bit and then retry
if strings.Contains(output, ".git/index.lock") {
c.Log.Error(output)
c.Log.Info("index.lock prevented command from running. Retrying command after a small wait")
attempt++
time.Sleep(waitTime)
if attempt < retryCount {
continue
}
}
}
return output, err
}
return cmdObj.RunWithOutput()
}
func (c *GitCommand) RunLineOutputCmd(cmdObj oscommands.ICmdObj, onLine func(line string) (bool, error)) error {
return c.OSCommand.RunLineOutputCmd(cmdObj, onLine)
return cmdObj.RunLineOutputCmd(onLine)
}
func (c *GitCommand) NewCmdObj(cmdStr string) oscommands.ICmdObj {
return c.OSCommand.NewCmdObj(cmdStr).AddEnvVars("GIT_OPTIONAL_LOCKS=0")
}
func (c *GitCommand) NewCmdObjWithLog(cmdStr string) oscommands.ICmdObj {
cmdObj := c.NewCmdObj(cmdStr)
c.OSCommand.LogCmdObj(cmdObj)
return cmdObj
return c.Cmd.New(cmdStr)
}
func (c *GitCommand) Quote(str string) string {

View File

@@ -0,0 +1,47 @@
package commands
import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/sirupsen/logrus"
)
// all we're doing here is wrapping the default command object builder with
// some git-specific stuff: e.g. adding a git-specific env var
type gitCmdObjBuilder struct {
innerBuilder *oscommands.CmdObjBuilder
}
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder {
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
log: log,
innerRunner: runner,
}
})
return &gitCmdObjBuilder{
innerBuilder: updatedBuilder,
}
}
var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0"
func (self *gitCmdObjBuilder) New(cmdStr string) oscommands.ICmdObj {
return self.innerBuilder.New(cmdStr).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) NewFromArgs(args []string) oscommands.ICmdObj {
return self.innerBuilder.NewFromArgs(args).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) NewShell(cmdStr string) oscommands.ICmdObj {
return self.innerBuilder.NewShell(cmdStr).AddEnvVars(defaultEnvVar)
}
func (self *gitCmdObjBuilder) Quote(str string) string {
return self.innerBuilder.Quote(str)
}

View File

@@ -0,0 +1,49 @@
package commands
import (
"strings"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/sirupsen/logrus"
)
// here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock
type gitCmdObjRunner struct {
log *logrus.Entry
innerRunner oscommands.ICmdObjRunner
}
func (self *gitCmdObjRunner) Run(cmdObj oscommands.ICmdObj) error {
_, err := self.RunWithOutput(cmdObj)
return err
}
func (self *gitCmdObjRunner) RunWithOutput(cmdObj oscommands.ICmdObj) (string, error) {
// TODO: have this retry logic in other places we run the command
waitTime := 50 * time.Millisecond
retryCount := 5
attempt := 0
for {
output, err := self.innerRunner.RunWithOutput(cmdObj)
if err != nil {
// if we have an error based on the index lock, we should wait a bit and then retry
if strings.Contains(output, ".git/index.lock") {
self.log.Error(output)
self.log.Info("index.lock prevented command from running. Retrying command after a small wait")
attempt++
time.Sleep(waitTime)
if attempt < retryCount {
continue
}
}
}
return output, err
}
}
func (self *gitCmdObjRunner) RunLineOutputCmd(cmdObj oscommands.ICmdObj, onLine func(line string) (bool, error)) error {
return self.innerRunner.RunLineOutputCmd(cmdObj, onLine)
}

View File

@@ -17,7 +17,7 @@ type ICmdObj interface {
RunLineOutputCmd(onLine func(line string) (bool, error)) error
// logs command
Log()
Log() ICmdObj
}
type CmdObj struct {
@@ -46,8 +46,10 @@ func (self *CmdObj) GetEnvVars() []string {
return self.cmd.Env
}
func (self *CmdObj) Log() {
func (self *CmdObj) Log() ICmdObj {
self.logCommand(self)
return self
}
func (self *CmdObj) Run() error {

View File

@@ -0,0 +1,72 @@
package oscommands
import (
"os"
"os/exec"
"strings"
"github.com/mgutz/str"
)
type ICmdObjBuilder interface {
// New returns a new command object based on the string provided
New(cmdStr string) ICmdObj
// NewShell takes a string like `git commit` and returns an executable shell command for it e.g. `sh -c 'git commit'`
NewShell(commandStr string) ICmdObj
// NewFromArgs takes a slice of strings like []string{"git", "commit"} and returns a new command object. This can be useful when you don't want to worry about whitespace and quoting and stuff.
NewFromArgs(args []string) ICmdObj
// Quote wraps a string in quotes with any necessary escaping applied. The reason for bundling this up with the other methods in this interface is that we basically always need to make use of this when creating new command objects.
Quote(str string) string
}
// poor man's version of explicitly saying that struct X implements interface Y
var _ ICmdObjBuilder = &CmdObjBuilder{}
type CmdObjBuilder struct {
runner ICmdObjRunner
logCmdObj func(ICmdObj)
// TODO: see if you can just remove this entirely and use secureexec.Command,
// now that we're mocking out the runner itself.
command func(string, ...string) *exec.Cmd
platform *Platform
}
func (self *CmdObjBuilder) New(cmdStr string) ICmdObj {
args := str.ToArgv(cmdStr)
cmd := self.command(args[0], args[1:]...)
cmd.Env = os.Environ()
return &CmdObj{
cmdStr: cmdStr,
cmd: cmd,
runner: self.runner,
logCommand: self.logCmdObj,
}
}
func (self *CmdObjBuilder) NewFromArgs(args []string) ICmdObj {
cmd := self.command(args[0], args[1:]...)
cmd.Env = os.Environ()
return &CmdObj{
cmdStr: strings.Join(args, " "),
cmd: cmd,
runner: self.runner,
logCommand: self.logCmdObj,
}
}
func (self *CmdObjBuilder) NewShell(commandStr string) ICmdObj {
return self.NewFromArgs([]string{self.platform.Shell, self.platform.ShellArg, commandStr})
}
func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdObjRunner) *CmdObjBuilder {
decoratedRunner := decorate(self.runner)
return &CmdObjBuilder{
runner: decoratedRunner,
logCmdObj: self.logCmdObj,
command: self.command,
platform: self.platform,
}
}

View File

@@ -0,0 +1,79 @@
package oscommands
import (
"bufio"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
)
type ICmdObjRunner interface {
Run(cmdObj ICmdObj) error
RunWithOutput(cmdObj ICmdObj) (string, error)
RunLineOutputCmd(cmdObj ICmdObj, onLine func(line string) (bool, error)) error
}
type RunExpectation func(ICmdObj) (string, error)
type RealRunner struct {
log *logrus.Entry
logCmdObj func(ICmdObj)
}
func (self *RealRunner) Run(cmdObj ICmdObj) error {
_, err := self.RunWithOutput(cmdObj)
return err
}
func (self *RealRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
self.logCmdObj(cmdObj)
output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput())
if err != nil {
self.log.WithField("command", cmdObj.ToString()).Error(output)
}
return output, err
}
func (self *RealRunner) RunLineOutputCmd(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
cmd := cmdObj.GetCmd()
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return err
}
scanner := bufio.NewScanner(stdoutPipe)
scanner.Split(bufio.ScanLines)
if err := cmd.Start(); err != nil {
return err
}
for scanner.Scan() {
line := scanner.Text()
stop, err := onLine(line)
if err != nil {
return err
}
if stop {
_ = cmd.Process.Kill()
break
}
}
_ = cmd.Wait()
return nil
}
func sanitisedCommandOutput(output []byte, err error) (string, error) {
outputString := string(output)
if err != nil {
// errors like 'exit status 1' are not very useful so we'll create an error
// from the combined output
if outputString == "" {
return "", utils.WrapError(err)
}
return outputString, errors.New(outputString)
}
return outputString, nil
}

View File

@@ -1,7 +1,6 @@
package oscommands
import (
"bufio"
"fmt"
"io/ioutil"
"os"
@@ -16,7 +15,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str"
)
// Platform stores the os state
@@ -55,9 +53,7 @@ type OSCommand struct {
removeFile func(string) error
Cmd ICmdObjBuilder
ICmdObjRunner
Cmd *CmdObjBuilder
}
// TODO: make these fields private
@@ -92,15 +88,20 @@ func NewCmdLogEntry(cmdStr string, span string, commandLine bool) CmdLogEntry {
// NewOSCommand os command runner
func NewOSCommand(common *common.Common) *OSCommand {
command := secureexec.Command
platform := getPlatform()
c := &OSCommand{
Common: common,
Platform: getPlatform(),
Command: secureexec.Command,
Platform: platform,
Command: command,
Getenv: os.Getenv,
removeFile: os.RemoveAll,
}
c.ICmdObjRunner = &RealRunner{c: c}
runner := &RealRunner{log: common.Log, logCmdObj: c.LogCmdObj}
c.Cmd = &CmdObjBuilder{runner: runner, command: command, logCmdObj: c.LogCmdObj, platform: platform}
return c
}
@@ -181,8 +182,12 @@ func (c *OSCommand) OpenLink(link string) error {
// Quote wraps a message in platform-specific quotation marks
func (c *OSCommand) Quote(message string) string {
return c.Cmd.Quote(message)
}
func (self *CmdObjBuilder) Quote(message string) string {
var quote string
if c.Platform.OS == "windows" {
if self.platform.OS == "windows" {
quote = `\"`
message = strings.NewReplacer(
`"`, `"'"'"`,
@@ -365,137 +370,16 @@ func (c *OSCommand) RemoveFile(path string) error {
return c.removeFile(path)
}
// builders
type ICmdObjBuilder interface {
// returns a new command object based on the string provided
New(cmdStr string) ICmdObj
NewShell(commandStr string) ICmdObj
NewFromArgs(args []string) ICmdObj
Quote(str string) string
}
func (c *OSCommand) NewCmdObj(cmdStr string) ICmdObj {
args := str.ToArgv(cmdStr)
cmd := c.Command(args[0], args[1:]...)
cmd.Env = os.Environ()
return &CmdObj{
cmdStr: cmdStr,
cmd: cmd,
runner: c.ICmdObjRunner,
logCommand: c.LogCmdObj,
}
return c.Cmd.New(cmdStr)
}
func (c *OSCommand) NewCmdObjFromArgs(args []string) ICmdObj {
cmd := c.Command(args[0], args[1:]...)
cmd.Env = os.Environ()
return &CmdObj{
cmdStr: strings.Join(args, " "),
cmd: cmd,
runner: c.ICmdObjRunner,
logCommand: c.LogCmdObj,
}
return c.Cmd.NewFromArgs(args)
}
// NewShellCmdObj takes a string like `git commit` and returns an executable shell command for it
func (c *OSCommand) NewShellCmdObj(commandStr string) ICmdObj {
quotedCommand := ""
// Windows does not seem to like quotes around the command
if c.Platform.OS == "windows" {
quotedCommand = strings.NewReplacer(
"^", "^^",
"&", "^&",
"|", "^|",
"<", "^<",
">", "^>",
"%", "^%",
).Replace(commandStr)
} else {
quotedCommand = c.Quote(commandStr)
}
shellCommand := fmt.Sprintf("%s %s %s", c.Platform.Shell, c.Platform.ShellArg, quotedCommand)
return c.NewCmdObj(shellCommand)
}
// TODO: pick one of NewShellCmdObj2 and ShellCommandFromString to use. I'm not sure
// which one actually is better, but I suspect it's NewShellCmdObj2
func (c *OSCommand) NewShellCmdObj2(command string) ICmdObj {
return c.NewCmdObjFromArgs([]string{c.Platform.Shell, c.Platform.ShellArg, command})
}
// runners
type ICmdObjRunner interface {
Run(cmdObj ICmdObj) error
RunWithOutput(cmdObj ICmdObj) (string, error)
RunLineOutputCmd(cmdObj ICmdObj, onLine func(line string) (bool, error)) error
}
type RunExpectation func(ICmdObj) (string, error)
type RealRunner struct {
c *OSCommand
}
func (self *RealRunner) Run(cmdObj ICmdObj) error {
_, err := self.RunWithOutput(cmdObj)
return err
}
func (self *RealRunner) RunWithOutput(cmdObj ICmdObj) (string, error) {
self.c.LogCmdObj(cmdObj)
output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput())
if err != nil {
self.c.Log.WithField("command", cmdObj.ToString()).Error(output)
}
return output, err
}
func (self *RealRunner) RunLineOutputCmd(cmdObj ICmdObj, onLine func(line string) (bool, error)) error {
cmd := cmdObj.GetCmd()
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return err
}
scanner := bufio.NewScanner(stdoutPipe)
scanner.Split(bufio.ScanLines)
if err := cmd.Start(); err != nil {
return err
}
for scanner.Scan() {
line := scanner.Text()
stop, err := onLine(line)
if err != nil {
return err
}
if stop {
_ = cmd.Process.Kill()
break
}
}
_ = cmd.Wait()
return nil
}
func sanitisedCommandOutput(output []byte, err error) (string, error) {
outputString := string(output)
if err != nil {
// errors like 'exit status 1' are not very useful so we'll create an error
// from the combined output
if outputString == "" {
return "", utils.WrapError(err)
}
return outputString, errors.New(outputString)
}
return outputString, nil
return c.Cmd.NewShell(commandStr)
}
func GetTempDir() string {

View File

@@ -111,14 +111,15 @@ func (c *GitCommand) SubmoduleDelete(submodule *models.SubmoduleConfig) error {
}
func (c *GitCommand) SubmoduleAdd(name string, path string, url string) error {
return c.OSCommand.Run(
c.OSCommand.NewCmdObj(
return c.Cmd.
New(
fmt.Sprintf(
"git submodule add --force --name %s -- %s %s ",
c.OSCommand.Quote(name),
c.OSCommand.Quote(url),
c.OSCommand.Quote(path),
)))
)).
Run()
}
func (c *GitCommand) SubmoduleUpdateUrl(name string, path string, newUrl string) error {

View File

@@ -244,7 +244,7 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
}
if customCommand.Subprocess {
return gui.runSubprocessWithSuspenseAndRefresh(gui.OSCommand.NewShellCmdObj2(cmdStr))
return gui.runSubprocessWithSuspenseAndRefresh(gui.OSCommand.Cmd.NewShell(cmdStr))
}
loadingText := customCommand.LoadingText

View File

@@ -465,7 +465,7 @@ func (gui *Gui) handleCommitEditorPress() error {
cmdStr := "git " + strings.Join(args, " ")
return gui.runSubprocessWithSuspenseAndRefresh(
gui.GitCommand.WithSpan(gui.Tr.Spans.Commit).NewCmdObjWithLog(cmdStr),
gui.GitCommand.WithSpan(gui.Tr.Spans.Commit).NewCmdObj(cmdStr).Log(),
)
}
@@ -923,7 +923,7 @@ func (gui *Gui) handleCustomCommand() error {
gui.OnRunCommand(oscommands.NewCmdLogEntry(command, gui.Tr.Spans.CustomCommand, true))
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.NewShellCmdObj2(command),
gui.OSCommand.NewShellCmdObj(command),
)
},
})

View File

@@ -32,7 +32,7 @@ func (gui *Gui) gitFlowFinishBranch(gitFlowConfig string, branchName string) err
}
return gui.runSubprocessWithSuspenseAndRefresh(
gui.GitCommand.WithSpan(gui.Tr.Spans.GitFlowFinish).NewCmdObjWithLog("git flow " + branchType + " finish " + suffix),
gui.GitCommand.WithSpan(gui.Tr.Spans.GitFlowFinish).NewCmdObj("git flow " + branchType + " finish " + suffix).Log(),
)
}
@@ -56,7 +56,7 @@ func (gui *Gui) handleCreateGitFlowMenu() error {
title: title,
handleConfirm: func(name string) error {
return gui.runSubprocessWithSuspenseAndRefresh(
gui.GitCommand.WithSpan(gui.Tr.Spans.GitFlowStart).NewCmdObjWithLog("git flow " + branchType + " start " + name),
gui.GitCommand.WithSpan(gui.Tr.Spans.GitFlowStart).NewCmdObj("git flow " + branchType + " start " + name).Log(),
)
},
})