Let a render refresh the index again on Windows

Renders were kept from refreshing git's index because a pty-rendered
command on Windows is terminated at an arbitrary instruction when its
task stops, and one landing in the window where git holds index.lock to
write back refreshed stat information leaves that lock behind.

A render no longer runs in a pty there, and nothing kills it any more
either: the pipe to the renderer breaks, git's write fails, and git dies
through its own die path with its lock files cleaned up. So let the
refresh happen, and let renders heal stale stat info the way they do
everywhere else.

The teardown of a pseudoconsole took its reassurance about index.lock
from this, and renders were the reason it held. What still runs in a pty
there is a custom command asking for logWithPty and a command that may
be asked for a credential, so the claim no longer follows; drop it
rather than restate it for clients it was never about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-09-25 11:31:59 +02:00
co-authored by Claude Opus 5
parent 087bdcd57f
commit 7a0f475414
4 changed files with 1 additions and 73 deletions
+1 -3
View File
@@ -144,9 +144,7 @@ func TerminateLivePtys() {
// graceful signal worth waiting on — git and the common diff tools leave it
// to the default handler, which calls ExitProcess at whatever instruction
// the process happens to execute — so clients that got the event are
// already dying. Killing at an arbitrary point cannot leak a stale
// index.lock, because pty-rendered commands don't take that lock (see
// withPtyGitConfig in pkg/gui/pty.go).
// already dying.
//
// The pseudoconsole close gets its own goroutine because the kill must not
// wait for it: on builds where ClosePseudoConsole blocks until the console
-2
View File
@@ -40,8 +40,6 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
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
-39
View File
@@ -4,8 +4,6 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
@@ -107,40 +105,3 @@ func (gui *Gui) ptyRender(spec renderSpec) (startRender, onCloseRender) {
return start, onClose
}
// 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:]...)
}
-29
View File
@@ -1,29 +0,0 @@
package gui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithPtyGitConfig(t *testing.T) {
args := []string{"git", "-C", "/repo", "diff", "--color=always"}
assert.Equal(t,
[]string{"git", "-c", "diff.autoRefreshIndex=false", "-C", "/repo", "diff", "--color=always"},
withPtyGitConfig(args, "windows"))
assert.Equal(t, args, withPtyGitConfig(args, "linux"))
assert.Equal(t, args, withPtyGitConfig(args, "darwin"))
// A user-configured command that wraps git in a shell must not have git
// flags injected into it.
shellArgs := []string{"sh", "-c", "git log --graph {{branchName}} -- | sed -e s/x/y/"}
assert.Equal(t, shellArgs, withPtyGitConfig(shellArgs, "windows"))
// The guard recognizes git regardless of case and extension.
exeArgs := []string{"GIT.EXE", "diff"}
assert.Equal(t,
[]string{"GIT.EXE", "-c", "diff.autoRefreshIndex=false", "diff"},
withPtyGitConfig(exeArgs, "windows"))
}