jesseduffield.lazygit/pkg/gui/pty.go
Stefan Haller 07ec8a72b6 Advertise the metadata protocol to git as well, not only to a pager
A rawGit diff renderer needs no pty -- git renders the diff itself, and
only a pager needs a terminal to be spawned at all -- so newPtyTask hands
that case straight to newCmdTask. But the OSC1717 advertisement was set
forty lines further down, past that early return, so git was never asked
to annotate its output and the word-diff renderer we just started
trusting emitted no records.

Set it before the branch, next to LAZYGIT_COLUMNS, which is there for the
same reason. Nothing else changes: a renderer that doesn't know the
variable ignores it, and git says nothing for the formats it doesn't
annotate, which is every format a rawGit renderer without word-diff
arguments produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

230 lines
8.9 KiB
Go

package gui
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"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) (cols, rows uint16) {
width, height := view.InnerSize()
return uint16(width), uint16(height)
}
func (gui *Gui) onResize() error {
gui.Mutexes.PtyMutex.Lock()
defer gui.Mutexes.PtyMutex.Unlock()
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)
cols, rows := gui.desiredPtySize(view)
if err := p.Resize(cols, rows); err != nil {
return utils.WrapError(err)
}
}
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) Terminate() error { return oscommands.TerminateProcessGracefully(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,
// which is just an io.Reader. the pty package lets us wrap a command in a
// pseudo-terminal meaning we'll get the behaviour we want from the underlying
// command.
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
width := view.InnerWidth()
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
// Advertise the diff-line metadata protocol versions we understand, so that
// whatever renders the diff annotates each line with an OSC sequence we can read
// back (see diff-line-metadata-notes.md). Set before the no-pty path below,
// because git renders the diff itself there and is one of the things that speaks
// the protocol — for its word-diff formats, whose inline markup we could not
// otherwise resolve. Anything that doesn't understand the variable ignores it, so
// this is safe to set unconditionally.
cmd.Env = append(cmd.Env, "OSC1717=V1")
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
// If we're not using a custom diff renderer, then we don't need to use a pty
return gui.newCmdTask(view, cmd, prefix)
}
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
// Mark the view as loading synchronously now, before the layout pass: the
// actual task is created in afterLayout (below), which runs after layout, so
// without this the next layout pass would clamp the scroll position to the
// not-yet-loaded content.
gui.getManager(view).StartLoading()
// Hold the scrollbar at its current height while the re-render loads, so the
// thumb doesn't shrink and snap back when the first partial paint swaps in
// (see the matching call in newCmdTask).
view.FreezeScrollbarHeight()
// Run the pty after layout so that it gets the correct size
gui.afterLayout(func() error {
// Need to get the width and the pager command again because the layout might have
// changed the size of the view
width = view.InnerWidth()
pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width)
cmdStr := strings.Join(cmd.Args, " ")
// This communicates to diff renderers that we're in a very simple
// terminal that they should not expect to have much capabilities.
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
// Context: https://github.com/jesseduffield/lazygit/issues/3419
cmd.Env = removeExistingTermEnvVars(cmd.Env)
cmd.Env = append(cmd.Env, "TERM=dumb")
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)
manager := gui.getManager(view)
// Size the pty from the view's dimensions here, on the UI thread; the
// start func below runs on the task's goroutine, which must not read the
// view's live dimensions while the UI thread is laying it out.
cols, rows := gui.desiredPtySize(view)
var p oscommands.Pty
var fallbackPipe io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
// The pty (and diff renderer) wrap to this width; apply it here, on the
// task's goroutine once the previous task has stopped, so it doesn't
// race that task's writes (see View.SetContentWidth).
view.SetContentWidth(width)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)
// Fall back to running the command without a pty: the diff renderer is
// lost, but the command's output still renders.
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
fallbackPipe = pipe
return execCmd, pipe
}
p = sp.Pty
gui.Mutexes.PtyMutex.Lock()
gui.viewPtmxMap[view.Name()] = p
gui.Mutexes.PtyMutex.Unlock()
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
}
onClose := func() {
gui.Mutexes.PtyMutex.Lock()
if p != nil {
p.Close()
}
if fallbackPipe != nil {
fallbackPipe.Close()
fallbackPipe = nil
}
delete(gui.viewPtmxMap, view.Name())
gui.Mutexes.PtyMutex.Unlock()
}
linesToRead := gui.linesToReadFromCmdTask(view)
// As in newCmdTask: if a restore is pending for this content (returning to a
// focused main view on escape), let the task re-establish the scroll
// position and selection as it first paints, reading to end of input so a
// deep target line is found and the scrollbar ends up accurate.
restore := manager.GetRestoreForNextTask()
if restore != nil {
linesToRead.Restore = restore
linesToRead.Total = -1
}
// New content scrolls back to the top at the first paint; same content keeps
// its scroll, and a restore places the scroll itself. See LinesToRead.ResetOrigin.
linesToRead.ResetOrigin = restore == nil && cmdStr != manager.GetTaskKey()
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
})
return nil
}
// withPtyGitConfig returns args with extra git configuration for commands
// that render into a pty. On Windows, such a command is terminated at an
// arbitrary point of its execution when its task stops: tearing down the
// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default
// handler, which just calls ExitProcess. git's automatic index refresh
// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a
// diff against the worktree to write back refreshed stat information —
// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing
// in that window leaves a stale index.lock behind that the next git command
// chokes on. So don't let pty-rendered commands refresh the index;
// lazygit's foreground `git status` refreshes, which never run in a pty,
// keep the stat cache fresh instead.
//
// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove
// its lock files, so the refresh can stay enabled there and keep healing
// stale stat info.
func withPtyGitConfig(args []string, goos string) []string {
if goos != "windows" {
return args
}
// Most pty commands are direct git invocations, but the user-configured
// ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git
// in `sh -c`), and injecting git flags into those would corrupt them.
// Only direct git invocations get the config; that loses nothing, since
// the wrapped commands are log commands, which never take the index
// lock. (For direct invocations other than worktree diffs the config is
// simply a no-op.)
base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe")
if base != "git" {
return args
}
result := make([]string, 0, len(args)+2)
result = append(result, args[0])
result = append(result, "-c", "diff.autoRefreshIndex=false")
return append(result, args[1:]...)
}
func removeExistingTermEnvVars(env []string) []string {
return lo.Filter(env, func(envVar string, _ int) bool {
return !isTermEnvVar(envVar)
})
}
// Terminals set a variety of different environment variables
// to identify themselves to processes. This list should catch the most common among them.
func isTermEnvVar(envVar string) bool {
return strings.HasPrefix(envVar, "TERM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
}