jesseduffield.lazygit/pkg/commands/git_commands/file_loader.go
Stefan Haller d94f2f05ac Only pass --no-optional-locks for background status refreshes
We set GIT_OPTIONAL_LOCKS=0 for every git command we run. That env var
only affects `git status`: it tells git not to take the optional lock it
would otherwise use to write the index back after refreshing the cached
stat information. The intent was to avoid contending for index.lock with
git commands the user runs in a terminal.

The downside is that our `git status` never persists the refreshed
stat-cache. So whenever the working tree's cached stat info goes stale
(e.g. editing files and discarding the changes, or a checkout), every
subsequent status re-hashes the affected files to confirm they're clean,
and stays slow until something else writes the index (such as the user
running `git status` in a terminal).

Fix this by only suppressing optional locks for refreshes that run
unattended in the background; foreground refreshes triggered by a user
action now run a plain `git status` that writes the refreshed index back,
just like the command line does. Background refreshes keep passing
--no-optional-locks so they still can't cause lock contention.

RefreshOptions gains a Background flag that the background routines set,
threaded down to the status command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:14:24 +02:00

223 lines
5.6 KiB
Go

package git_commands
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)
type FileLoaderConfig interface {
GetShowUntrackedFiles() string
}
type FileLoader struct {
*GitCommon
cmd oscommands.ICmdObjBuilder
config FileLoaderConfig
getFileType func(string) string
}
func NewFileLoader(gitCommon *GitCommon, cmd oscommands.ICmdObjBuilder, config FileLoaderConfig) *FileLoader {
return &FileLoader{
GitCommon: gitCommon,
cmd: cmd,
getFileType: oscommands.FileType,
config: config,
}
}
type GetStatusFileOptions struct {
NoRenames bool
// If true, we'll show untracked files even if the user has set the config to hide them.
// This is useful for users with bare repos for dotfiles who default to hiding untracked files,
// but want to occasionally see them to `git add` a new file.
ForceShowUntracked bool
// When true, this status is part of an unattended background refresh, so we
// pass --no-optional-locks to avoid index.lock contention with git commands
// the user runs in a terminal (at the cost of not persisting git's refreshed
// stat-cache).
Background bool
}
func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File {
// check if config wants us ignoring untracked files
untrackedFilesSetting := self.config.GetShowUntrackedFiles()
if opts.ForceShowUntracked || untrackedFilesSetting == "" {
untrackedFilesSetting = "all"
}
untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg, Background: opts.Background})
if err != nil {
self.Log.Error(err)
}
files := []*models.File{}
fileDiffs := map[string]FileDiff{}
if self.GitCommon.Common.UserConfig().Gui.ShowNumstatInFilesView {
fileDiffs, err = self.getFileDiffs()
if err != nil {
self.Log.Error(err)
}
}
for _, status := range statuses {
if strings.HasPrefix(status.StatusString, "warning") {
self.Log.Warningf("warning when calling git status: %s", status.StatusString)
continue
}
file := &models.File{
Path: status.Path,
PreviousPath: status.PreviousPath,
DisplayString: status.StatusString,
}
if diff, ok := fileDiffs[status.Path]; ok {
file.LinesAdded = diff.LinesAdded
file.LinesDeleted = diff.LinesDeleted
}
models.SetStatusFields(file, status.Change)
files = append(files, file)
}
// Go through the files to see if any of these files are actually worktrees
// so that we can render them correctly
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
for _, file := range files {
for _, worktreePath := range worktreePaths {
absFilePath, err := filepath.Abs(file.Path)
if err != nil {
self.Log.Error(err)
continue
}
if absFilePath == worktreePath {
file.IsWorktree = true
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
// If we include the slash, it will be rendered as a folder with a null file inside.
file.Path = strings.TrimSuffix(file.Path, "/")
break
}
}
}
return files
}
type FileDiff struct {
LinesAdded int
LinesDeleted int
}
func (self *FileLoader) getFileDiffs() (map[string]FileDiff, error) {
diffs, err := self.gitDiffNumStat()
if err != nil {
return nil, err
}
splitLines := strings.Split(diffs, "\x00")
fileDiffs := map[string]FileDiff{}
for _, line := range splitLines {
splitLine := strings.Split(line, "\t")
if len(splitLine) != 3 {
continue
}
linesAdded, err := strconv.Atoi(splitLine[0])
if err != nil {
continue
}
linesDeleted, err := strconv.Atoi(splitLine[1])
if err != nil {
continue
}
fileName := splitLine[2]
fileDiffs[fileName] = FileDiff{
LinesAdded: linesAdded,
LinesDeleted: linesDeleted,
}
}
return fileDiffs, nil
}
// GitStatus returns the file status of the repo
type GitStatusOptions struct {
NoRenames bool
UntrackedFilesArg string
Background bool
}
type FileStatus struct {
StatusString string
Change string // ??, MM, AM, ...
Path string
PreviousPath string
}
func (self *FileLoader) gitDiffNumStat() (string, error) {
return self.cmd.New(
NewGitCmd("diff").
Arg("--numstat").
Arg("-z").
Arg("HEAD").
ToArgv(),
).DontLog().RunWithOutput()
}
func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) {
cmdArgs := NewGitCmd("status").
GlobalArgIf(opts.Background, "--no-optional-locks").
Arg(opts.UntrackedFilesArg).
Arg("--porcelain").
Arg("-z").
ArgIfElse(
opts.NoRenames,
"--no-renames",
fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold),
).
ToArgv()
statusLines, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return []FileStatus{}, err
}
splitLines := strings.Split(statusLines, "\x00")
response := []FileStatus{}
for i := 0; i < len(splitLines); i++ {
original := splitLines[i]
if len(original) < 3 {
continue
}
status := FileStatus{
StatusString: original,
Change: original[:2],
Path: original[3:],
PreviousPath: "",
}
if strings.HasPrefix(status.Change, "R") || strings.HasPrefix(status.Change, "C") {
// if a line starts with 'R' (rename) or 'C' (copy) then the next line is the original file.
status.PreviousPath = splitLines[i+1]
status.StatusString = fmt.Sprintf("%s %s -> %s", status.Change, status.PreviousPath, status.Path)
i++
}
response = append(response, status)
}
return response, nil
}