jesseduffield.lazygit/pkg/commands/oscommands/os.go
Stefan Haller d2a1a4f2a2 Start all commands of a pipeline before waiting for any of them
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>
2026-08-06 14:34:47 +02:00

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) + `"`
}