1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-07-28 16:02:01 +03:00
This commit is contained in:
Jesse Duffield
2021-12-29 14:33:38 +11:00
parent 192a548c99
commit 43a4fa970d
41 changed files with 539 additions and 419 deletions

View File

@ -5,18 +5,27 @@ import (
)
// A command object is a general way to represent a command to be run on the
// command line. If you want to log the command you'll use .ToString() and
// if you want to run it you'll use .GetCmd()
// command line.
type ICmdObj interface {
GetCmd() *exec.Cmd
ToString() string
AddEnvVars(...string) ICmdObj
GetEnvVars() []string
Run() error
RunWithOutput() (string, error)
RunLineOutputCmd(onLine func(line string) (bool, error)) error
// logs command
Log() ICmdObj
}
type CmdObj struct {
cmdStr string
cmd *exec.Cmd
runner ICmdObjRunner
logCommand func(ICmdObj)
}
func (self *CmdObj) GetCmd() *exec.Cmd {
@ -36,3 +45,21 @@ func (self *CmdObj) AddEnvVars(vars ...string) ICmdObj {
func (self *CmdObj) GetEnvVars() []string {
return self.cmd.Env
}
func (self *CmdObj) Log() ICmdObj {
self.logCommand(self)
return self
}
func (self *CmdObj) Run() error {
return self.runner.Run(self)
}
func (self *CmdObj) RunWithOutput() (string, error) {
return self.runner.RunWithOutput(self)
}
func (self *CmdObj) RunLineOutputCmd(onLine func(line string) (bool, error)) error {
return self.runner.RunLineOutputCmd(self, onLine)
}

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,7 +53,7 @@ type OSCommand struct {
removeFile func(string) error
IRunner
Cmd *CmdObjBuilder
}
// TODO: make these fields private
@ -90,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.IRunner = &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
}
@ -162,8 +165,7 @@ func (c *OSCommand) OpenFile(filename string) error {
"filename": c.Quote(filename),
}
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
err := c.Run(c.NewShellCmdObjFromString(command))
return err
return c.Cmd.NewShell(command).Run()
}
// OpenLink opens a file with the given
@ -175,14 +177,17 @@ func (c *OSCommand) OpenLink(link string) error {
}
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
err := c.Run(c.NewShellCmdObjFromString(command))
return err
return c.Cmd.NewShell(command).Run()
}
// 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(
`"`, `"'"'"`,
@ -289,7 +294,7 @@ func (c *OSCommand) PipeCommands(commandStrings ...string) error {
logCmdStr += " | "
}
logCmdStr += str
cmds[i] = c.NewCmdObj(str).GetCmd()
cmds[i] = c.Cmd.New(str).GetCmd()
}
c.LogCommand(logCmdStr, true)
@ -365,127 +370,6 @@ func (c *OSCommand) RemoveFile(path string) error {
return c.removeFile(path)
}
// builders
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,
}
}
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,
}
}
// NewShellCmdObjFromString takes a string like `git commit` and returns an executable shell command for it
func (c *OSCommand) NewShellCmdObjFromString(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 NewShellCmdObjFromString2 and ShellCommandFromString to use. I'm not sure
// which one actually is better, but I suspect it's NewShellCmdObjFromString2
func (c *OSCommand) NewShellCmdObjFromString2(command string) ICmdObj {
return c.NewCmdObjFromArgs([]string{c.Platform.Shell, c.Platform.ShellArg, command})
}
// runners
type IRunner 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
}
func GetTempDir() string {
return filepath.Join(os.TempDir(), "lazygit")
}

View File

@ -33,7 +33,7 @@ func TestOSCommandRunWithOutput(t *testing.T) {
for _, s := range scenarios {
c := NewDummyOSCommand()
s.test(NewDummyOSCommand().RunWithOutput(c.NewCmdObj(s.command)))
s.test(c.Cmd.New(s.command).RunWithOutput())
}
}
@ -55,7 +55,7 @@ func TestOSCommandRun(t *testing.T) {
for _, s := range scenarios {
c := NewDummyOSCommand()
s.test(c.Run(c.NewCmdObj(s.command)))
s.test(c.Cmd.New(s.command)).Run()
}
}