1package oscommands 2 3import ( 4 "bufio" 5 "fmt" 6 "io/ioutil" 7 "os" 8 "os/exec" 9 "path/filepath" 10 "strings" 11 "sync" 12 13 "github.com/go-errors/errors" 14 15 "github.com/atotto/clipboard" 16 "github.com/jesseduffield/lazygit/pkg/config" 17 "github.com/jesseduffield/lazygit/pkg/secureexec" 18 "github.com/jesseduffield/lazygit/pkg/utils" 19 "github.com/mgutz/str" 20 "github.com/sirupsen/logrus" 21) 22 23// Platform stores the os state 24type Platform struct { 25 OS string 26 Shell string 27 ShellArg string 28 OpenCommand string 29 OpenLinkCommand string 30} 31 32// OSCommand holds all the os commands 33type OSCommand struct { 34 Log *logrus.Entry 35 Platform *Platform 36 Config config.AppConfigurer 37 Command func(string, ...string) *exec.Cmd 38 BeforeExecuteCmd func(*exec.Cmd) 39 Getenv func(string) string 40 41 // callback to run before running a command, i.e. for the purposes of logging 42 onRunCommand func(CmdLogEntry) 43 44 // something like 'Staging File': allows us to group cmd logs under a single title 45 CmdLogSpan string 46 47 removeFile func(string) error 48} 49 50// TODO: make these fields private 51type CmdLogEntry struct { 52 // e.g. 'git commit -m "haha"' 53 cmdStr string 54 // Span is something like 'Staging File'. Multiple commands can be grouped under the same 55 // span 56 span string 57 58 // sometimes our command is direct like 'git commit', and sometimes it's a 59 // command to remove a file but through Go's standard library rather than the 60 // command line 61 commandLine bool 62} 63 64func (e CmdLogEntry) GetCmdStr() string { 65 return e.cmdStr 66} 67 68func (e CmdLogEntry) GetSpan() string { 69 return e.span 70} 71 72func (e CmdLogEntry) GetCommandLine() bool { 73 return e.commandLine 74} 75 76func NewCmdLogEntry(cmdStr string, span string, commandLine bool) CmdLogEntry { 77 return CmdLogEntry{cmdStr: cmdStr, span: span, commandLine: commandLine} 78} 79 80// NewOSCommand os command runner 81func NewOSCommand(log *logrus.Entry, config config.AppConfigurer) *OSCommand { 82 return &OSCommand{ 83 Log: log, 84 Platform: getPlatform(), 85 Config: config, 86 Command: secureexec.Command, 87 BeforeExecuteCmd: func(*exec.Cmd) {}, 88 Getenv: os.Getenv, 89 removeFile: os.RemoveAll, 90 } 91} 92 93func (c *OSCommand) WithSpan(span string) *OSCommand { 94 // sometimes .WithSpan(span) will be called where span actually is empty, in 95 // which case we don't need to log anything so we can just return early here 96 // with the original struct 97 if span == "" { 98 return c 99 } 100 101 newOSCommand := &OSCommand{} 102 *newOSCommand = *c 103 newOSCommand.CmdLogSpan = span 104 return newOSCommand 105} 106 107func (c *OSCommand) LogExecCmd(cmd *exec.Cmd) { 108 c.LogCommand(strings.Join(cmd.Args, " "), true) 109} 110 111func (c *OSCommand) LogCommand(cmdStr string, commandLine bool) { 112 c.Log.WithField("command", cmdStr).Info("RunCommand") 113 114 if c.onRunCommand != nil && c.CmdLogSpan != "" { 115 c.onRunCommand(NewCmdLogEntry(cmdStr, c.CmdLogSpan, commandLine)) 116 } 117} 118 119func (c *OSCommand) SetOnRunCommand(f func(CmdLogEntry)) { 120 c.onRunCommand = f 121} 122 123// SetCommand sets the command function used by the struct. 124// To be used for testing only 125func (c *OSCommand) SetCommand(cmd func(string, ...string) *exec.Cmd) { 126 c.Command = cmd 127} 128 129// To be used for testing only 130func (c *OSCommand) SetRemoveFile(f func(string) error) { 131 c.removeFile = f 132} 133 134func (c *OSCommand) SetBeforeExecuteCmd(cmd func(*exec.Cmd)) { 135 c.BeforeExecuteCmd = cmd 136} 137 138type RunCommandOptions struct { 139 EnvVars []string 140} 141 142func (c *OSCommand) RunCommandWithOutputWithOptions(command string, options RunCommandOptions) (string, error) { 143 c.LogCommand(command, true) 144 cmd := c.ExecutableFromString(command) 145 146 cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0") // prevents git from prompting us for input which would freeze the program 147 cmd.Env = append(cmd.Env, options.EnvVars...) 148 149 return sanitisedCommandOutput(cmd.CombinedOutput()) 150} 151 152func (c *OSCommand) RunCommandWithOptions(command string, options RunCommandOptions) error { 153 _, err := c.RunCommandWithOutputWithOptions(command, options) 154 return err 155} 156 157// RunCommandWithOutput wrapper around commands returning their output and error 158// NOTE: If you don't pass any formatArgs we'll just use the command directly, 159// however there's a bizarre compiler error/warning when you pass in a formatString 160// with a percent sign because it thinks it's supposed to be a formatString when 161// in that case it's not. To get around that error you'll need to define the string 162// in a variable and pass the variable into RunCommandWithOutput. 163func (c *OSCommand) RunCommandWithOutput(formatString string, formatArgs ...interface{}) (string, error) { 164 command := formatString 165 if formatArgs != nil { 166 command = fmt.Sprintf(formatString, formatArgs...) 167 } 168 cmd := c.ExecutableFromString(command) 169 c.LogExecCmd(cmd) 170 output, err := sanitisedCommandOutput(cmd.CombinedOutput()) 171 if err != nil { 172 c.Log.WithField("command", command).Error(output) 173 } 174 return output, err 175} 176 177// RunExecutableWithOutput runs an executable file and returns its output 178func (c *OSCommand) RunExecutableWithOutput(cmd *exec.Cmd) (string, error) { 179 c.LogExecCmd(cmd) 180 c.BeforeExecuteCmd(cmd) 181 return sanitisedCommandOutput(cmd.CombinedOutput()) 182} 183 184// RunExecutable runs an executable file and returns an error if there was one 185func (c *OSCommand) RunExecutable(cmd *exec.Cmd) error { 186 _, err := c.RunExecutableWithOutput(cmd) 187 return err 188} 189 190// ExecutableFromString takes a string like `git status` and returns an executable command for it 191func (c *OSCommand) ExecutableFromString(commandStr string) *exec.Cmd { 192 splitCmd := str.ToArgv(commandStr) 193 cmd := c.Command(splitCmd[0], splitCmd[1:]...) 194 cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") 195 return cmd 196} 197 198// ShellCommandFromString takes a string like `git commit` and returns an executable shell command for it 199func (c *OSCommand) ShellCommandFromString(commandStr string) *exec.Cmd { 200 quotedCommand := "" 201 // Windows does not seem to like quotes around the command 202 if c.Platform.OS == "windows" { 203 quotedCommand = strings.NewReplacer( 204 "^", "^^", 205 "&", "^&", 206 "|", "^|", 207 "<", "^<", 208 ">", "^>", 209 "%", "^%", 210 ).Replace(commandStr) 211 } else { 212 quotedCommand = c.Quote(commandStr) 213 } 214 215 shellCommand := fmt.Sprintf("%s %s %s", c.Platform.Shell, c.Platform.ShellArg, quotedCommand) 216 return c.ExecutableFromString(shellCommand) 217} 218 219// RunCommand runs a command and just returns the error 220func (c *OSCommand) RunCommand(formatString string, formatArgs ...interface{}) error { 221 _, err := c.RunCommandWithOutput(formatString, formatArgs...) 222 return err 223} 224 225// RunShellCommand runs shell commands i.e. 'sh -c <command>'. Good for when you 226// need access to the shell 227func (c *OSCommand) RunShellCommand(command string) error { 228 cmd := c.ShellCommandFromString(command) 229 c.LogExecCmd(cmd) 230 231 _, err := sanitisedCommandOutput(cmd.CombinedOutput()) 232 233 return err 234} 235 236// FileType tells us if the file is a file, directory or other 237func (c *OSCommand) FileType(path string) string { 238 fileInfo, err := os.Stat(path) 239 if err != nil { 240 return "other" 241 } 242 if fileInfo.IsDir() { 243 return "directory" 244 } 245 return "file" 246} 247 248func sanitisedCommandOutput(output []byte, err error) (string, error) { 249 outputString := string(output) 250 if err != nil { 251 // errors like 'exit status 1' are not very useful so we'll create an error 252 // from the combined output 253 if outputString == "" { 254 return "", utils.WrapError(err) 255 } 256 return outputString, errors.New(outputString) 257 } 258 return outputString, nil 259} 260 261// OpenFile opens a file with the given 262func (c *OSCommand) OpenFile(filename string) error { 263 commandTemplate := c.Config.GetUserConfig().OS.OpenCommand 264 templateValues := map[string]string{ 265 "filename": c.Quote(filename), 266 } 267 command := utils.ResolvePlaceholderString(commandTemplate, templateValues) 268 err := c.RunShellCommand(command) 269 return err 270} 271 272// OpenLink opens a file with the given 273func (c *OSCommand) OpenLink(link string) error { 274 c.LogCommand(fmt.Sprintf("Opening link '%s'", link), false) 275 commandTemplate := c.Config.GetUserConfig().OS.OpenLinkCommand 276 templateValues := map[string]string{ 277 "link": c.Quote(link), 278 } 279 280 command := utils.ResolvePlaceholderString(commandTemplate, templateValues) 281 err := c.RunShellCommand(command) 282 return err 283} 284 285// PrepareSubProcess iniPrepareSubProcessrocess then tells the Gui to switch to it 286// TODO: see if this needs to exist, given that ExecutableFromString does the same things 287func (c *OSCommand) PrepareSubProcess(cmdName string, commandArgs ...string) *exec.Cmd { 288 cmd := c.Command(cmdName, commandArgs...) 289 if cmd != nil { 290 cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0") 291 } 292 c.LogExecCmd(cmd) 293 return cmd 294} 295 296// PrepareShellSubProcess returns the pointer to a custom command 297func (c *OSCommand) PrepareShellSubProcess(command string) *exec.Cmd { 298 return c.PrepareSubProcess(c.Platform.Shell, c.Platform.ShellArg, command) 299} 300 301// Quote wraps a message in platform-specific quotation marks 302func (c *OSCommand) Quote(message string) string { 303 var quote string 304 if c.Platform.OS == "windows" { 305 quote = `\"` 306 message = strings.NewReplacer( 307 `"`, `"'"'"`, 308 `\"`, `\\"`, 309 ).Replace(message) 310 } else { 311 quote = `"` 312 message = strings.NewReplacer( 313 `\`, `\\`, 314 `"`, `\"`, 315 `$`, `\$`, 316 "`", "\\`", 317 ).Replace(message) 318 } 319 return quote + message + quote 320} 321 322// AppendLineToFile adds a new line in file 323func (c *OSCommand) AppendLineToFile(filename, line string) error { 324 c.LogCommand(fmt.Sprintf("Appending '%s' to file '%s'", line, filename), false) 325 f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) 326 if err != nil { 327 return utils.WrapError(err) 328 } 329 defer f.Close() 330 331 _, err = f.WriteString("\n" + line) 332 if err != nil { 333 return utils.WrapError(err) 334 } 335 return nil 336} 337 338// CreateTempFile writes a string to a new temp file and returns the file's name 339func (c *OSCommand) CreateTempFile(filename, content string) (string, error) { 340 tmpfile, err := ioutil.TempFile("", filename) 341 if err != nil { 342 c.Log.Error(err) 343 return "", utils.WrapError(err) 344 } 345 c.LogCommand(fmt.Sprintf("Creating temp file '%s'", tmpfile.Name()), false) 346 347 if _, err := tmpfile.WriteString(content); err != nil { 348 c.Log.Error(err) 349 return "", utils.WrapError(err) 350 } 351 if err := tmpfile.Close(); err != nil { 352 c.Log.Error(err) 353 return "", utils.WrapError(err) 354 } 355 356 return tmpfile.Name(), nil 357} 358 359// CreateFileWithContent creates a file with the given content 360func (c *OSCommand) CreateFileWithContent(path string, content string) error { 361 c.LogCommand(fmt.Sprintf("Creating file '%s'", path), false) 362 if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { 363 c.Log.Error(err) 364 return err 365 } 366 367 if err := ioutil.WriteFile(path, []byte(content), 0644); err != nil { 368 c.Log.Error(err) 369 return utils.WrapError(err) 370 } 371 372 return nil 373} 374 375// Remove removes a file or directory at the specified path 376func (c *OSCommand) Remove(filename string) error { 377 c.LogCommand(fmt.Sprintf("Removing '%s'", filename), false) 378 err := os.RemoveAll(filename) 379 return utils.WrapError(err) 380} 381 382// FileExists checks whether a file exists at the specified path 383func (c *OSCommand) FileExists(path string) (bool, error) { 384 if _, err := os.Stat(path); err != nil { 385 if os.IsNotExist(err) { 386 return false, nil 387 } 388 return false, err 389 } 390 return true, nil 391} 392 393// RunPreparedCommand takes a pointer to an exec.Cmd and runs it 394// this is useful if you need to give your command some environment variables 395// before running it 396func (c *OSCommand) RunPreparedCommand(cmd *exec.Cmd) error { 397 c.BeforeExecuteCmd(cmd) 398 c.LogExecCmd(cmd) 399 out, err := cmd.CombinedOutput() 400 outString := string(out) 401 c.Log.Info(outString) 402 if err != nil { 403 if len(outString) == 0 { 404 return err 405 } 406 return errors.New(outString) 407 } 408 return nil 409} 410 411// GetLazygitPath returns the path of the currently executed file 412func (c *OSCommand) GetLazygitPath() string { 413 ex, err := os.Executable() // get the executable path for git to use 414 if err != nil { 415 ex = os.Args[0] // fallback to the first call argument if needed 416 } 417 return `"` + filepath.ToSlash(ex) + `"` 418} 419 420// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C 421func (c *OSCommand) PipeCommands(commandStrings ...string) error { 422 cmds := make([]*exec.Cmd, len(commandStrings)) 423 logCmdStr := "" 424 for i, str := range commandStrings { 425 if i > 0 { 426 logCmdStr += " | " 427 } 428 logCmdStr += str 429 cmds[i] = c.ExecutableFromString(str) 430 } 431 c.LogCommand(logCmdStr, true) 432 433 for i := 0; i < len(cmds)-1; i++ { 434 stdout, err := cmds[i].StdoutPipe() 435 if err != nil { 436 return err 437 } 438 439 cmds[i+1].Stdin = stdout 440 } 441 442 // keeping this here in case I adapt this code for some other purpose in the future 443 // cmds[len(cmds)-1].Stdout = os.Stdout 444 445 finalErrors := []string{} 446 447 wg := sync.WaitGroup{} 448 wg.Add(len(cmds)) 449 450 for _, cmd := range cmds { 451 currentCmd := cmd 452 go utils.Safe(func() { 453 stderr, err := currentCmd.StderrPipe() 454 if err != nil { 455 c.Log.Error(err) 456 } 457 458 if err := currentCmd.Start(); err != nil { 459 c.Log.Error(err) 460 } 461 462 if b, err := ioutil.ReadAll(stderr); err == nil { 463 if len(b) > 0 { 464 finalErrors = append(finalErrors, string(b)) 465 } 466 } 467 468 if err := currentCmd.Wait(); err != nil { 469 c.Log.Error(err) 470 } 471 472 wg.Done() 473 }) 474 } 475 476 wg.Wait() 477 478 if len(finalErrors) > 0 { 479 return errors.New(strings.Join(finalErrors, "\n")) 480 } 481 return nil 482} 483 484func Kill(cmd *exec.Cmd) error { 485 if cmd.Process == nil { 486 // somebody got to it before we were able to, poor bastard 487 return nil 488 } 489 return cmd.Process.Kill() 490} 491 492func RunLineOutputCmd(cmd *exec.Cmd, onLine func(line string) (bool, error)) error { 493 stdoutPipe, err := cmd.StdoutPipe() 494 if err != nil { 495 return err 496 } 497 498 scanner := bufio.NewScanner(stdoutPipe) 499 scanner.Split(bufio.ScanLines) 500 if err := cmd.Start(); err != nil { 501 return err 502 } 503 504 for scanner.Scan() { 505 line := scanner.Text() 506 stop, err := onLine(line) 507 if err != nil { 508 return err 509 } 510 if stop { 511 _ = cmd.Process.Kill() 512 break 513 } 514 } 515 516 _ = cmd.Wait() 517 518 return nil 519} 520 521func (c *OSCommand) CopyToClipboard(str string) error { 522 escaped := strings.Replace(str, "\n", "\\n", -1) 523 truncated := utils.TruncateWithEllipsis(escaped, 40) 524 c.LogCommand(fmt.Sprintf("Copying '%s' to clipboard", truncated), false) 525 return clipboard.WriteAll(str) 526} 527 528func (c *OSCommand) RemoveFile(path string) error { 529 c.LogCommand(fmt.Sprintf("Deleting path '%s'", path), false) 530 531 return c.removeFile(path) 532} 533 534func (c *OSCommand) NewCmdObjFromStr(cmdStr string) ICmdObj { 535 args := str.ToArgv(cmdStr) 536 cmd := c.Command(args[0], args[1:]...) 537 cmd.Env = os.Environ() 538 539 return &CmdObj{ 540 cmdStr: cmdStr, 541 cmd: cmd, 542 } 543} 544 545func (c *OSCommand) NewCmdObjFromArgs(args []string) ICmdObj { 546 cmd := c.Command(args[0], args[1:]...) 547 548 return &CmdObj{ 549 cmdStr: strings.Join(args, " "), 550 cmd: cmd, 551 } 552} 553 554func (c *OSCommand) NewCmdObj(cmd *exec.Cmd) ICmdObj { 555 return &CmdObj{ 556 cmdStr: strings.Join(cmd.Args, " "), 557 cmd: cmd, 558 } 559} 560