Files
Stefan HallerandClaude Opus 5 80ad2db71a Recognize worktrees among the files from the worktrees model
Finding out which of the files are worktrees of ours had its own answer
to where this repo's worktrees are, walking the directory that git keeps
them in. The worktrees panel asks git itself, and that is the better
answer: it is the one git gives for the same question elsewhere in the
app, and it doesn't need to know where git records what.

The model that panel fills is all the files need, so mark them from it.
That takes the work out of the file loader, whose other two callers were
paying for it without wanting it, and it costs no git call at all: both
models are written on the UI thread, so whichever of the two refreshes
lands second marks the files against the other's fresh data.

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

272 lines
7.1 KiB
Go

package git_commands
import (
"fmt"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/samber/lo"
)
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)
}
self.setConflictMarkerSizes(files)
return files
}
// Looks up how long the conflict markers in the conflicted files are. We ask
// git for all of them at once, because spawning a process per file would be
// painfully slow when hundreds of files are conflicted (especially on Windows).
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) {
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool {
return file.HasInlineMergeConflicts
})
if len(conflictedFiles) == 0 {
return
}
paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string {
return file.Path
})
markerSizes, err := self.getConflictMarkerSizes(paths)
if err != nil {
self.Log.Error(err)
return
}
for _, file := range conflictedFiles {
file.ConflictMarkerSize = markerSizes[file.Path]
}
}
func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) {
cmdArgs := NewGitCmd("check-attr").
Arg("-z").
Arg("--stdin").
Arg("conflict-marker-size").
ToArgv()
// -z makes git both read the paths and write its output NUL-separated, so
// that paths containing newlines don't throw us off.
output, _, err := self.cmd.New(cmdArgs).
SetStdin(strings.Join(paths, "\x00")).
DontLog().
RunWithOutputs()
if err != nil {
return nil, err
}
markerSizes := map[string]int{}
fields := strings.Split(output, "\x00")
// Each path yields a path/attribute/value triple; the value is either a
// number or something like "unspecified", in which case we leave the marker
// size at 0 to say that git's default applies.
for i := 0; i+2 < len(fields); i += 3 {
if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 {
markerSizes[fields[i]] = markerSize
}
}
return markerSizes, nil
}
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
}