From ee011eff2aba0558f7b3404e343cc7bc2b78508f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Apr 2026 22:00:53 +0200 Subject: [PATCH] Abstract task command over *exec.Cmd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows ConPTY can't attach a child process to a pseudoconsole via os/exec — Go's stdlib doesn't expose PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE (golang/go#62708). The ConPTY path has to call CreateProcess directly, so it can't hand an *exec.Cmd back to the task runner. Widen NewCmdTask to accept a small Cmd interface satisfied by both *exec.Cmd (via the ExecCmd adapter) and the Windows ConPTY command type we're about to add. Change TerminateProcessGracefully to take *os.Process, which both cmd shapes can provide. Behavior is unchanged on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oscommands/os_default_platform.go | 7 ++--- pkg/commands/oscommands/os_windows.go | 3 +- pkg/gui/pty.go | 5 ++-- pkg/gui/tasks_adapter.go | 4 +-- pkg/tasks/tasks.go | 28 ++++++++++++++++--- pkg/tasks/tasks_test.go | 12 ++++---- 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/pkg/commands/oscommands/os_default_platform.go b/pkg/commands/oscommands/os_default_platform.go index 06684434e..891fca364 100644 --- a/pkg/commands/oscommands/os_default_platform.go +++ b/pkg/commands/oscommands/os_default_platform.go @@ -4,7 +4,6 @@ package oscommands import ( "os" - "os/exec" "runtime" "strings" "syscall" @@ -40,10 +39,10 @@ func (c *OSCommand) UpdateWindowTitle() error { return nil } -func TerminateProcessGracefully(cmd *exec.Cmd) error { - if cmd.Process == nil { +func TerminateProcessGracefully(proc *os.Process) error { + if proc == nil { return nil } - return cmd.Process.Signal(syscall.SIGTERM) + return proc.Signal(syscall.SIGTERM) } diff --git a/pkg/commands/oscommands/os_windows.go b/pkg/commands/oscommands/os_windows.go index 605ed7682..f1f45655b 100644 --- a/pkg/commands/oscommands/os_windows.go +++ b/pkg/commands/oscommands/os_windows.go @@ -3,7 +3,6 @@ package oscommands import ( "fmt" "os" - "os/exec" "path/filepath" ) @@ -24,7 +23,7 @@ func (c *OSCommand) UpdateWindowTitle() error { return c.Cmd.NewShell(argString, c.UserConfig().OS.ShellFunctionsFile).Run() } -func TerminateProcessGracefully(cmd *exec.Cmd) error { +func TerminateProcessGracefully(proc *os.Process) error { // Signals other than SIGKILL are not supported on Windows return nil } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index f6356b9c0..9cd0e8a00 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -10,6 +10,7 @@ import ( "github.com/creack/pty" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -75,7 +76,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) var ptmx *os.File - start := func() (*exec.Cmd, io.Reader) { + start := func() (tasks.Cmd, io.Reader) { var err error ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize(view)) if err != nil { @@ -86,7 +87,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.viewPtmxMap[view.Name()] = ptmx gui.Mutexes.PtyMutex.Unlock() - return cmd, ptmx + return tasks.ExecCmd{Cmd: cmd}, ptmx } onClose := func() { diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3bfc64100..09edd2d36 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -19,7 +19,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) var r io.ReadCloser - start := func() (*exec.Cmd, io.Reader) { + start := func() (tasks.Cmd, io.Reader) { var err error r, err = cmd.StdoutPipe() if err != nil { @@ -32,7 +32,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.c.Log.Error(err) } - return cmd, r + return tasks.ExecCmd{Cmd: cmd}, r } onClose := func() { diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c2964a8b9..dba59a990 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "io" + "os" "os/exec" "sync" "time" @@ -15,6 +16,25 @@ import ( "github.com/sirupsen/logrus" ) +// Cmd abstracts over a started external process. *exec.Cmd satisfies the bulk +// of it via ExecCmd, but pty implementations can supply their own types — on +// Windows, ConPTY has to spawn via CreateProcess directly and can't use +// *exec.Cmd (see golang/go#62708). +type Cmd interface { + Wait() error + String() string + GetProcess() *os.Process +} + +// ExecCmd adapts *exec.Cmd to Cmd. +type ExecCmd struct { + *exec.Cmd +} + +func (c ExecCmd) GetProcess() *os.Process { + return c.Process +} + // This file revolves around running commands that will be output to the main panel // in the gui. If we're flicking through the commits panel, we want to invoke a // `git show` command for each commit, but we don't want to read the entire output @@ -117,7 +137,7 @@ func (self *ViewBufferManager) ReadToEnd(then func()) { } } -func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error { +func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error { return func(opts TaskOpts) error { var onDoneOnce sync.Once var onFirstPageShownOnce sync.Once @@ -173,8 +193,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p // // Unfortunately this will do nothing on Windows, so Windows users will have to live // with the higher CPU usage. - if err := oscommands.TerminateProcessGracefully(cmd); err != nil { - self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v %v", err, cmd.Path, cmd.Args) + if err := oscommands.TerminateProcessGracefully(cmd.GetProcess()); err != nil { + self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String()) } // close the task's stdout pipe (or the pty if we're using one) to make the command terminate @@ -316,7 +336,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p go func() { _ = cmd.Wait() }() default: if err := cmd.Wait(); err != nil { - self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v %v", err, cmd.Path, cmd.Args) + self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v", err, cmd.String()) } } diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 40ff0033d..c025e8e16 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -43,13 +43,13 @@ func TestNewCmdTaskInstantStop(t *testing.T) { stop := make(chan struct{}) reader := bytes.NewBufferString("test") - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") close(stop) - return cmd, reader + return ExecCmd{Cmd: cmd}, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) @@ -108,11 +108,11 @@ func TestNewCmdTask(t *testing.T) { stop := make(chan struct{}) reader := bytes.NewBufferString("test") - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") - return cmd, reader + return ExecCmd{Cmd: cmd}, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) @@ -241,11 +241,11 @@ func TestNewCmdTaskRefresh(t *testing.T) { stop := make(chan struct{}) reader := BlankLineReader{totalLinesToYield: s.totalTaskLines} - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") - return cmd, &reader + return ExecCmd{Cmd: cmd}, &reader } fn := manager.NewCmdTask(start, "", s.linesToRead, func() {})