mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
PipeCommands ran every command in its own goroutine, each doing Start/read-stderr/Wait, with nothing ordering one goroutine's Start against another's Wait. That ordering matters: StdoutPipe registers the parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those descriptors when it returns. The next command's Stdin is that very *os.File, and exec passes a user-supplied *os.File through untouched, so Start hands the child whatever the fd happens to be at that moment. If the producer finished and got reaped before the consumer's goroutine reached Start, that fd was already closed, File.Fd() returned -1, and the child was started with fd 0 closed -- reading nothing at all. The only caller is the pre-2.35 fallback in SaveStagedChanges, which pipes `git stash show -p` into `git apply -R`. Losing that race left git apply with an empty patch, so it failed with "unrecognized input", the following `git stash drop` never ran, and the user was left with a stray stash entry. This turned up as a flaky stash/stash_staged on the git 2.32.0 CI job; the newer-git jobs take the `git stash push --staged` path and never reach this code. Starting every command up front removes the race, and collecting stderr into buffers lets exec's own copying goroutines do the work. That also fixes two lesser problems in the same function: finalErrors was appended to from several goroutines without synchronization, and a failed Start was only logged, so a pipeline that never ran reported success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
374 lines
8.6 KiB
Go
374 lines
8.6 KiB
Go
package oscommands
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-errors/errors"
|
|
"github.com/samber/lo"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/jesseduffield/lazygit/pkg/common"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
)
|
|
|
|
// OSCommand holds all the os commands
|
|
type OSCommand struct {
|
|
*common.Common
|
|
Platform *Platform
|
|
getenvFn func(string) string
|
|
guiIO *guiIO
|
|
|
|
removeFileFn func(string) error
|
|
isDirEmptyFn func(string) (bool, error)
|
|
removeDirFn func(string) error
|
|
|
|
Cmd *CmdObjBuilder
|
|
|
|
tempDir string
|
|
}
|
|
|
|
// Platform stores the os state
|
|
type Platform struct {
|
|
OS string
|
|
Shell string
|
|
ShellArg string
|
|
PrefixForShellFunctionsFile string
|
|
OpenCommand string
|
|
OpenLinkCommand string
|
|
}
|
|
|
|
// NewOSCommand os command runner
|
|
func NewOSCommand(common *common.Common, config config.AppConfigurer, platform *Platform, guiIO *guiIO) *OSCommand {
|
|
c := &OSCommand{
|
|
Common: common,
|
|
Platform: platform,
|
|
getenvFn: os.Getenv,
|
|
removeFileFn: os.RemoveAll,
|
|
isDirEmptyFn: isDirEmpty,
|
|
removeDirFn: os.Remove,
|
|
guiIO: guiIO,
|
|
tempDir: config.GetTempDir(),
|
|
}
|
|
|
|
runner := &cmdObjRunner{log: common.Log, guiIO: guiIO}
|
|
c.Cmd = &CmdObjBuilder{runner: runner, platform: platform}
|
|
|
|
return c
|
|
}
|
|
|
|
func (c *OSCommand) LogCommand(cmdStr string, commandLine bool) {
|
|
c.Log.WithField("command", cmdStr).Info("RunCommand")
|
|
|
|
c.guiIO.logCommandFn(cmdStr, commandLine)
|
|
}
|
|
|
|
// FileType tells us if the file is a file, directory or other
|
|
func FileType(path string) string {
|
|
fileInfo, err := os.Stat(path)
|
|
if err != nil {
|
|
return "other"
|
|
}
|
|
if fileInfo.IsDir() {
|
|
return "directory"
|
|
}
|
|
return "file"
|
|
}
|
|
|
|
func (c *OSCommand) OpenFile(filename string) error {
|
|
commandTemplate := c.UserConfig().OS.Open
|
|
if commandTemplate == "" {
|
|
commandTemplate = config.GetPlatformDefaultConfig().Open
|
|
}
|
|
templateValues := map[string]string{
|
|
"filename": c.Quote(filename),
|
|
}
|
|
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
|
|
return c.Cmd.NewShell(command, c.UserConfig().OS.ShellFunctionsFile).Run()
|
|
}
|
|
|
|
func (c *OSCommand) OpenLink(link string) error {
|
|
commandTemplate := c.UserConfig().OS.OpenLink
|
|
if commandTemplate == "" {
|
|
commandTemplate = config.GetPlatformDefaultConfig().OpenLink
|
|
}
|
|
templateValues := map[string]string{
|
|
"link": c.Quote(link),
|
|
}
|
|
|
|
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
|
|
return c.Cmd.NewShell(command, c.UserConfig().OS.ShellFunctionsFile).Run()
|
|
}
|
|
|
|
// Quote wraps a message in platform-specific quotation marks
|
|
func (c *OSCommand) Quote(message string) string {
|
|
return c.Cmd.Quote(message)
|
|
}
|
|
|
|
// AppendLineToFile adds a new line in file
|
|
func (c *OSCommand) AppendLineToFile(filename, line string) error {
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.AppendingLineToFile,
|
|
map[string]string{
|
|
"line": line,
|
|
"filename": filename,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
|
|
f, err := os.OpenFile(filename, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0o600)
|
|
if err != nil {
|
|
return utils.WrapError(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := os.Stat(filename)
|
|
if err != nil {
|
|
return utils.WrapError(err)
|
|
}
|
|
|
|
if info.Size() > 0 {
|
|
// read last char
|
|
buf := make([]byte, 1)
|
|
if _, err := f.ReadAt(buf, info.Size()-1); err != nil {
|
|
return utils.WrapError(err)
|
|
}
|
|
|
|
// if the last byte of the file is not a newline, add it
|
|
if []byte("\n")[0] != buf[0] {
|
|
_, err = f.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if err == nil {
|
|
_, err = f.WriteString(line + "\n")
|
|
}
|
|
|
|
if err != nil {
|
|
return utils.WrapError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateFileWithContent creates a file with the given content
|
|
func (c *OSCommand) CreateFileWithContent(path string, content string) error {
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.CreateFileWithContent,
|
|
map[string]string{
|
|
"path": path,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
|
c.Log.Error(err)
|
|
return err
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
c.Log.Error(err)
|
|
return utils.WrapError(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Remove removes a file or directory at the specified path
|
|
func (c *OSCommand) Remove(filename string) error {
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.Remove,
|
|
map[string]string{
|
|
"filename": filename,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
err := os.RemoveAll(filename)
|
|
return utils.WrapError(err)
|
|
}
|
|
|
|
// FileExists checks whether a file exists at the specified path
|
|
func (c *OSCommand) FileExists(path string) (bool, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
|
|
func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
|
cmds := lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) *exec.Cmd {
|
|
return cmdObj.GetCmd()
|
|
})
|
|
|
|
logCmdStr := strings.Join(
|
|
lo.Map(cmdObjs, func(cmdObj *CmdObj, _ int) string {
|
|
return cmdObj.ToString()
|
|
}),
|
|
" | ",
|
|
)
|
|
|
|
c.LogCommand(logCmdStr, true)
|
|
|
|
for i := range len(cmds) - 1 {
|
|
stdout, err := cmds[i].StdoutPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmds[i+1].Stdin = stdout
|
|
}
|
|
|
|
// keeping this here in case I adapt this code for some other purpose in the future
|
|
// cmds[len(cmds)-1].Stdout = os.Stdout
|
|
|
|
stderrs := make([]bytes.Buffer, len(cmds))
|
|
for i := range cmds {
|
|
cmds[i].Stderr = &stderrs[i]
|
|
}
|
|
|
|
// Start every command before waiting for any of them: waiting for a command
|
|
// closes our end of the pipe that feeds the next one, and a command that
|
|
// hasn't been started by then would inherit a closed stdin.
|
|
started := 0
|
|
var startErr error
|
|
for _, cmd := range cmds {
|
|
if err := cmd.Start(); err != nil {
|
|
startErr = err
|
|
break
|
|
}
|
|
|
|
started++
|
|
}
|
|
|
|
finalErrors := []string{}
|
|
|
|
if startErr != nil {
|
|
c.Log.Error(startErr)
|
|
finalErrors = append(finalErrors, startErr.Error())
|
|
|
|
// Without the rest of the pipeline to drain them, the commands we did
|
|
// start could block forever writing to a full pipe.
|
|
for _, cmd := range cmds[:started] {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
for i, cmd := range cmds[:started] {
|
|
if err := cmd.Wait(); err != nil {
|
|
c.Log.Error(err)
|
|
}
|
|
|
|
if stderrs[i].Len() > 0 {
|
|
finalErrors = append(finalErrors, stderrs[i].String())
|
|
}
|
|
}
|
|
|
|
if len(finalErrors) > 0 {
|
|
return errors.New(strings.Join(finalErrors, "\n"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *OSCommand) CopyToClipboard(str string) error {
|
|
escaped := strings.ReplaceAll(str, "\n", "\\n")
|
|
truncated := utils.TruncateWithEllipsis(escaped, 40)
|
|
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.CopyToClipboard,
|
|
map[string]string{
|
|
"str": truncated,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
if c.UserConfig().OS.CopyToClipboardCmd != "" {
|
|
cmdStr := utils.ResolvePlaceholderString(c.UserConfig().OS.CopyToClipboardCmd, map[string]string{
|
|
"text": c.Cmd.Quote(str),
|
|
})
|
|
return c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).Run()
|
|
}
|
|
|
|
return clipboard.WriteAll(str)
|
|
}
|
|
|
|
func (c *OSCommand) PasteFromClipboard() (string, error) {
|
|
var s string
|
|
var err error
|
|
if c.UserConfig().OS.CopyToClipboardCmd != "" {
|
|
cmdStr := c.UserConfig().OS.ReadFromClipboardCmd
|
|
s, err = c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).RunWithOutput()
|
|
} else {
|
|
s, err = clipboard.ReadAll()
|
|
}
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strings.ReplaceAll(s, "\r\n", "\n"), nil
|
|
}
|
|
|
|
func (c *OSCommand) RemoveFile(path string) error {
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.RemoveFile,
|
|
map[string]string{
|
|
"path": path,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
|
|
return c.removeFileFn(path)
|
|
}
|
|
|
|
func (c *OSCommand) IsDirEmpty(path string) (bool, error) {
|
|
return c.isDirEmptyFn(path)
|
|
}
|
|
|
|
func (c *OSCommand) RemoveDir(path string) error {
|
|
msg := utils.ResolvePlaceholderString(
|
|
c.Tr.Log.RemoveEmptyDir,
|
|
map[string]string{
|
|
"path": path,
|
|
},
|
|
)
|
|
c.LogCommand(msg, false)
|
|
|
|
return c.removeDirFn(path)
|
|
}
|
|
|
|
func isDirEmpty(path string) (bool, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_, err = f.Readdirnames(1)
|
|
_ = f.Close()
|
|
if errors.Is(err, io.EOF) {
|
|
return true, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
func (c *OSCommand) Getenv(key string) string {
|
|
return c.getenvFn(key)
|
|
}
|
|
|
|
func (c *OSCommand) GetTempDir() string {
|
|
return c.tempDir
|
|
}
|
|
|
|
// GetLazygitPath returns the path of the currently executed file
|
|
func GetLazygitPath() string {
|
|
ex, err := os.Executable() // get the executable path for git to use
|
|
if err != nil {
|
|
ex = os.Args[0] // fallback to the first call argument if needed
|
|
}
|
|
return `"` + filepath.ToSlash(ex) + `"`
|
|
}
|