Abstract pty startup behind a platform-specific primitive

Move the pty master behind a small interface (Read/Write/Close/Resize),
and push the actual startup into a platform-specific StartPty function
in pkg/commands/oscommands. The Unix implementation still uses
creack/pty; the Windows implementation is a stub that returns
ErrPtyUnsupported, at which point newPtyTask falls back to a plain cmd
task — matching the existing Windows behavior.

The primitive lives in oscommands rather than pkg/gui because the
cmd_obj_runner pty handler (also in oscommands) is going to consume it
too, and tasks → oscommands is the existing dependency direction.

Same observable behavior on every platform; this just carves out a seam
for a real ConPTY implementation on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-30 09:45:26 +02:00
parent 8ec4283e55
commit c85f7530bb
6 changed files with 120 additions and 34 deletions

View file

@ -0,0 +1,39 @@
package oscommands
import (
"errors"
"io"
"os"
)
// Pty is the master side of a pseudo-terminal running a subprocess. The
// concrete implementation is platform-specific: creack/pty on Unix and
// ConPTY on Windows.
type Pty interface {
io.ReadWriteCloser
Resize(cols, rows uint16) error
}
// StartedPty is the result of StartPty.
type StartedPty struct {
// Pty is the master side of the pseudo-terminal; read from it to get
// the child's combined stdout/stderr and write to it to feed stdin.
Pty Pty
// Process is the spawned child. Useful for signalling; on Windows the
// original *exec.Cmd was not Start()ed (ConPTY spawns via
// CreateProcess, not os/exec) so cmd.Process is nil and this is the
// only handle.
Process *os.Process
// Wait blocks until the child exits and returns a non-nil error on a
// nonzero exit status, matching *exec.Cmd.Wait semantics.
Wait func() error
}
// ErrPtyUnsupported is returned by StartPty on platforms without a pty
// implementation. Callers may fall back to running the command without a pty.
var ErrPtyUnsupported = errors.New("pty not supported on this platform")
// StartPty runs cmd in a pseudo-terminal with the given initial dimensions.
// Implemented per-platform in pty_unix.go / pty_windows.go.
//
// func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error)

View file

@ -0,0 +1,34 @@
//go:build !windows
package oscommands
import (
"os"
"os/exec"
creackpty "github.com/creack/pty"
)
type unixPty struct {
master *os.File
}
func (u *unixPty) Read(p []byte) (int, error) { return u.master.Read(p) }
func (u *unixPty) Write(p []byte) (int, error) { return u.master.Write(p) }
func (u *unixPty) Close() error { return u.master.Close() }
func (u *unixPty) Resize(cols, rows uint16) error {
return creackpty.Setsize(u.master, &creackpty.Winsize{Cols: cols, Rows: rows})
}
func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) {
f, err := creackpty.StartWithSize(cmd, &creackpty.Winsize{Cols: cols, Rows: rows})
if err != nil {
return StartedPty{}, err
}
return StartedPty{
Pty: &unixPty{master: f},
Process: cmd.Process,
Wait: cmd.Wait,
}, nil
}

View file

@ -0,0 +1,12 @@
package oscommands
import (
"os/exec"
)
// StartPty is a stub on Windows for now; callers fall back to the non-pty
// path when ErrPtyUnsupported is returned. A real ConPTY implementation
// replaces this in a follow-up commit.
func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) {
return StartedPty{}, ErrPtyUnsupported
}

View file

@ -85,7 +85,7 @@ type Gui struct {
// holds a mapping of view names to ptmx's. This is for rendering command outputs
// from within a pty. The point of keeping track of them is so that if we re-size
// the window, we can tell the pty it needs to resize accordingly.
viewPtmxMap map[string]*os.File
viewPtmxMap map[string]oscommands.Pty
stopChan chan struct{}
// when lazygit is opened outside a git directory we want to open to the most
@ -761,7 +761,7 @@ func NewGui(
Updater: updater,
statusManager: status.NewStatusManager(),
viewBufferManagerMap: map[string]*tasks.ViewBufferManager{},
viewPtmxMap: map[string]*os.File{},
viewPtmxMap: map[string]oscommands.Pty{},
showRecentRepos: showRecentRepos,
RepoPathStack: &utils.StringStack{},
RepoStateMap: map[Repo]*GuiRepoState{},

View file

@ -1,37 +1,36 @@
//go:build !windows
package gui
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/creack/pty"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
func (gui *Gui) desiredPtySize(view *gocui.View) *pty.Winsize {
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
width, height := view.InnerSize()
return &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}
return uint16(width), uint16(height)
}
func (gui *Gui) onResize() error {
gui.Mutexes.PtyMutex.Lock()
defer gui.Mutexes.PtyMutex.Unlock()
for viewName, ptmx := range gui.viewPtmxMap {
for viewName, p := range gui.viewPtmxMap {
// TODO: handle resizing properly: we need to actually clear the main view
// and re-read the output from our pty. Or we could just re-run the original
// command from scratch
view, _ := gui.g.View(viewName)
if err := pty.Setsize(ptmx, gui.desiredPtySize(view)); err != nil {
cols, rows := gui.desiredPtySize(view)
if err := p.Resize(cols, rows); err != nil {
return utils.WrapError(err)
}
}
@ -39,6 +38,19 @@ func (gui *Gui) onResize() error {
return nil
}
// ptyCmd adapts an oscommands.StartedPty result into the tasks.Cmd shape.
// On Windows the original *exec.Cmd was never Start()ed, so we go through
// the explicit Process handle rather than cmd.Process.
type ptyCmd struct {
cmd *exec.Cmd
process *os.Process
wait func() error
}
func (p ptyCmd) Wait() error { return p.wait() }
func (p ptyCmd) String() string { return p.cmd.String() }
func (p ptyCmd) GetProcess() *os.Process { return p.process }
// Some commands need to output for a terminal to active certain behaviour.
// For example, git won't invoke the GIT_PAGER env var unless it thinks it's
// talking to a terminal. We typically write cmd outputs straight to a view,
@ -82,24 +94,30 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
var ptmx *os.File
var p oscommands.Pty
start := func() (tasks.Cmd, io.Reader) {
var err error
ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize(view))
cols, rows := gui.desiredPtySize(view)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)
if !errors.Is(err, oscommands.ErrPtyUnsupported) {
gui.c.Log.Error(err)
}
return tasks.ExecCmd{Cmd: cmd}, nil
}
p = sp.Pty
gui.Mutexes.PtyMutex.Lock()
gui.viewPtmxMap[view.Name()] = ptmx
gui.viewPtmxMap[view.Name()] = p
gui.Mutexes.PtyMutex.Unlock()
return tasks.ExecCmd{Cmd: cmd}, ptmx
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
}
onClose := func() {
gui.Mutexes.PtyMutex.Lock()
ptmx.Close()
if p != nil {
p.Close()
}
delete(gui.viewPtmxMap, view.Name())
gui.Mutexes.PtyMutex.Unlock()
}

View file

@ -1,17 +0,0 @@
package gui
import (
"fmt"
"os/exec"
"github.com/jesseduffield/lazygit/pkg/gocui"
)
func (gui *Gui) onResize() error {
return nil
}
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", view.InnerWidth()))
return gui.newCmdTask(view, cmd, prefix)
}