Skip to content

Commit f89d6e9

Browse files
committed
perf: skip untracked file enumeration when staging hunks of tracked files
When staging hunks in the staging panel, lazygit refreshes the files list by running git status --untracked-files=all. In large repos (e.g. 275k files) this directory walk dominates the refresh time, causing multi-second delays after each staging operation. Since staging hunks of a tracked file cannot change the set of untracked files, we can safely skip their enumeration by using --untracked-files=no and preserving the untracked files from the previous model state. The optimization is only applied when the file being staged is tracked. Staging an untracked file (status ??) still triggers a full refresh to correctly capture the ?? to AM transition. Fixes #5455
1 parent eb351dc commit f89d6e9

File tree

4 files changed

+60
-13
lines changed

4 files changed

+60
-13
lines changed

pkg/commands/git_commands/file_loader.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,25 @@ type GetStatusFileOptions struct {
3636
// This is useful for users with bare repos for dotfiles who default to hiding untracked files,
3737
// but want to occasionally see them to `git add` a new file.
3838
ForceShowUntracked bool
39+
// If true, pass --untracked-files=no to skip enumerating untracked files.
40+
// This is a performance optimization for refreshes that only affect tracked
41+
// files, avoiding a costly directory walk in large repos.
42+
NoUntracked bool
3943
}
4044

4145
func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File {
42-
// check if config wants us ignoring untracked files
43-
untrackedFilesSetting := self.config.GetShowUntrackedFiles()
44-
45-
if opts.ForceShowUntracked || untrackedFilesSetting == "" {
46-
untrackedFilesSetting = "all"
46+
var untrackedFilesArg string
47+
if opts.NoUntracked {
48+
untrackedFilesArg = "--untracked-files=no"
49+
} else {
50+
// check if config wants us ignoring untracked files
51+
untrackedFilesSetting := self.config.GetShowUntrackedFiles()
52+
53+
if opts.ForceShowUntracked || untrackedFilesSetting == "" {
54+
untrackedFilesSetting = "all"
55+
}
56+
untrackedFilesArg = fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
4757
}
48-
untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
4958

5059
statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg})
5160
if err != nil {

pkg/gui/controllers/helpers/refresh_helper.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
150150
if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) {
151151
fileWg.Add(1)
152152
refresh("files", func() {
153-
_ = self.refreshFilesAndSubmodules()
153+
_ = self.refreshFilesAndSubmodules(options.KeepUntrackedFiles)
154154
fileWg.Done()
155155
})
156156
}
@@ -504,7 +504,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
504504
self.refreshStatus()
505505
}
506506

507-
func (self *RefreshHelper) refreshFilesAndSubmodules() error {
507+
func (self *RefreshHelper) refreshFilesAndSubmodules(keepUntrackedFiles bool) error {
508508
self.c.Mutexes().RefreshingFilesMutex.Lock()
509509
self.c.State().SetIsRefreshingFiles(true)
510510
defer func() {
@@ -516,7 +516,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
516516
return err
517517
}
518518

519-
if err := self.refreshStateFiles(); err != nil {
519+
if err := self.refreshStateFiles(keepUntrackedFiles); err != nil {
520520
return err
521521
}
522522

@@ -529,7 +529,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
529529
return nil
530530
}
531531

532-
func (self *RefreshHelper) refreshStateFiles() error {
532+
func (self *RefreshHelper) refreshStateFiles(keepUntrackedFiles bool) error {
533533
fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel
534534

535535
prevConflictFileCount := 0
@@ -564,11 +564,28 @@ func (self *RefreshHelper) refreshStateFiles() error {
564564
}
565565
}
566566

567+
// When keepUntrackedFiles is true, we skip enumerating untracked files
568+
// in git status (avoiding a costly directory walk in large repos) and
569+
// preserve the untracked files from the previous model state instead.
570+
var previousUntrackedFiles []*models.File
571+
if keepUntrackedFiles {
572+
for _, file := range self.c.Model().Files {
573+
if !file.Tracked {
574+
previousUntrackedFiles = append(previousUntrackedFiles, file)
575+
}
576+
}
577+
}
578+
567579
files := self.c.Git().Loaders.FileLoader.
568580
GetStatusFiles(git_commands.GetStatusFileOptions{
569-
ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(),
581+
ForceShowUntracked: !keepUntrackedFiles && self.c.Contexts().Files.ForceShowUntracked(),
582+
NoUntracked: keepUntrackedFiles,
570583
})
571584

585+
if keepUntrackedFiles {
586+
files = append(files, previousUntrackedFiles...)
587+
}
588+
572589
conflictFileCount := 0
573590
for _, file := range files {
574591
if file.HasMergeConflicts {

pkg/gui/controllers/staging_controller.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,19 @@ func (self *StagingController) DiscardSelection() error {
226226
}
227227

228228
func (self *StagingController) applySelectionAndRefresh(reverse bool) error {
229+
// Check if the file is tracked before applying, so we can optimize the
230+
// refresh by skipping untracked file enumeration in large repos.
231+
file := self.c.Contexts().Files.FileTreeViewModel.GetSelectedFile()
232+
isTracked := file != nil && file.Tracked
233+
229234
if err := self.applySelection(reverse); err != nil {
230235
return err
231236
}
232237

233-
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
238+
self.c.Refresh(types.RefreshOptions{
239+
Scope: []types.RefreshableView{types.FILES, types.STAGING},
240+
KeepUntrackedFiles: isTracked,
241+
})
234242
return nil
235243
}
236244

@@ -281,11 +289,17 @@ func (self *StagingController) applySelection(reverse bool) error {
281289
}
282290

283291
func (self *StagingController) EditHunkAndRefresh() error {
292+
file := self.c.Contexts().Files.FileTreeViewModel.GetSelectedFile()
293+
isTracked := file != nil && file.Tracked
294+
284295
if err := self.editHunk(); err != nil {
285296
return err
286297
}
287298

288-
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}})
299+
self.c.Refresh(types.RefreshOptions{
300+
Scope: []types.RefreshableView{types.FILES, types.STAGING},
301+
KeepUntrackedFiles: isTracked,
302+
})
289303
return nil
290304
}
291305

pkg/gui/types/refresh.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,11 @@ type RefreshOptions struct {
4343
// keeps the selection index the same. Useful after checking out a detached
4444
// head, and selecting index 0.
4545
KeepBranchSelectionIndex bool
46+
47+
// When true, skip enumerating untracked files during the files refresh
48+
// and preserve untracked files from the previous model state instead.
49+
// This is a performance optimization for operations that only affect
50+
// tracked files (e.g. staging hunks), avoiding a costly
51+
// git status --untracked-files=all in large repos.
52+
KeepUntrackedFiles bool
4653
}

0 commit comments

Comments
 (0)