mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 08:06:25 -04:00
Commit d94f2f05 dropped the GIT_OPTIONAL_LOCKS=0 env var that we used
to set on every git command, and re-added lock suppression only as a
--no-optional-locks flag on the background files refresh. The intent
was sound — a foreground `git status` should persist git's refreshed
stat-cache — but the change was too broad: it stopped suppressing
optional locks for every other command too.
The one that bites is the main-view diff. When a folder containing
submodules is selected, we render `git diff --submodule -- <dir>`, and
`--submodule` makes git run `git status` inside each submodule to
describe its "modified" state. That status now grabs the submodule's
index.lock. It runs as a PTY task on its own goroutine, so it races
any submodule-mutating action the user triggers — e.g. resetting a
submodule runs `git -C <submodule> stash`, which then fails with
"index.lock: File exists". This is what made submodule/reset_folder
flaky. `git status` is in fact the only command that takes the
optional lock, but the env var also covered its use inside `git diff
--submodule`, inside PTY-run commands, and inside git's own submodule
child processes — none of which a per-command flag reaches cleanly.
Invert the polarity to match how it worked before d94f2f05: the git
command builder disables optional locks on every command by default,
and the single command that benefits from taking the lock — the
foreground files refresh — opts back in. This restores the original
contention avoidance (including against the user's terminal git) while
keeping d94f2f05's stat-cache-persistence win for the foreground
refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
6.1 KiB
Go
233 lines
6.1 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 it
|
|
// keeps the default suppression of optional locks (avoiding index.lock
|
|
// contention with git commands the user runs in a terminal, at the cost of
|
|
// not persisting git's refreshed stat-cache). A foreground status opts back
|
|
// in; see gitStatus.
|
|
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").
|
|
Arg(opts.UntrackedFilesArg).
|
|
Arg("--porcelain").
|
|
Arg("-z").
|
|
ArgIfElse(
|
|
opts.NoRenames,
|
|
"--no-renames",
|
|
fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold),
|
|
).
|
|
ToArgv()
|
|
|
|
cmdObj := self.cmd.New(cmdArgs).DontLog()
|
|
if !opts.Background {
|
|
// Every git command suppresses optional locks by default (see
|
|
// OptionalLocksEnvVar). A foreground refresh is the one exception: we let
|
|
// it take the lock so it persists git's refreshed stat-cache, which keeps
|
|
// subsequent status calls fast. Background refreshes leave it suppressed so
|
|
// they can't contend for index.lock.
|
|
cmdObj.RemoveEnvVar(OptionalLocksEnvVar)
|
|
}
|
|
|
|
statusLines, _, err := cmdObj.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
|
|
}
|