mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
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>
272 lines
7.1 KiB
Go
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
|
|
}
|