From 25cced360018d5886a03f55ca1bd194de56e175e Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Fri, 3 Apr 2026 12:39:57 +0000 Subject: [PATCH] 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 --- pkg/commands/git_commands/file_loader.go | 21 ++++++++++----- pkg/gui/controllers/helpers/refresh_helper.go | 27 +++++++++++++++---- pkg/gui/controllers/staging_controller.go | 18 +++++++++++-- pkg/gui/types/refresh.go | 7 +++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 36ab8ef6742..ad23b2a3555 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -36,16 +36,25 @@ type GetStatusFileOptions struct { // 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 + // If true, pass --untracked-files=no to skip enumerating untracked files. + // This is a performance optimization for refreshes that only affect tracked + // files, avoiding a costly directory walk in large repos. + NoUntracked 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" + var untrackedFilesArg string + if opts.NoUntracked { + untrackedFilesArg = "--untracked-files=no" + } else { + // 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) } - untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting) statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg}) if err != nil { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 126018da681..0cd8fbc84c9 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -169,7 +169,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) { fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules() + _ = self.refreshFilesAndSubmodules(options.KeepUntrackedFiles) fileWg.Done() }) } @@ -539,7 +539,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.refreshStatus() } -func (self *RefreshHelper) refreshFilesAndSubmodules() error { +func (self *RefreshHelper) refreshFilesAndSubmodules(keepUntrackedFiles bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() self.c.State().SetIsRefreshingFiles(true) defer func() { @@ -551,7 +551,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error { return err } - if err := self.refreshStateFiles(); err != nil { + if err := self.refreshStateFiles(keepUntrackedFiles); err != nil { return err } @@ -564,7 +564,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error { return nil } -func (self *RefreshHelper) refreshStateFiles() error { +func (self *RefreshHelper) refreshStateFiles(keepUntrackedFiles bool) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel prevConflictFileCount := 0 @@ -599,11 +599,28 @@ func (self *RefreshHelper) refreshStateFiles() error { } } + // When keepUntrackedFiles is true, we skip enumerating untracked files + // in git status (avoiding a costly directory walk in large repos) and + // preserve the untracked files from the previous model state instead. + var previousUntrackedFiles []*models.File + if keepUntrackedFiles { + for _, file := range self.c.Model().Files { + if !file.Tracked { + previousUntrackedFiles = append(previousUntrackedFiles, file) + } + } + } + files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ - ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + ForceShowUntracked: !keepUntrackedFiles && self.c.Contexts().Files.ForceShowUntracked(), + NoUntracked: keepUntrackedFiles, }) + if keepUntrackedFiles { + files = append(files, previousUntrackedFiles...) + } + conflictFileCount := 0 for _, file := range files { if file.HasMergeConflicts { diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index f667dd212c3..c6ebcbfc939 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -226,11 +226,19 @@ func (self *StagingController) DiscardSelection() error { } func (self *StagingController) applySelectionAndRefresh(reverse bool) error { + // Check if the file is tracked before applying, so we can optimize the + // refresh by skipping untracked file enumeration in large repos. + file := self.c.Contexts().Files.FileTreeViewModel.GetSelectedFile() + isTracked := file != nil && file.Tracked + if err := self.applySelection(reverse); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES, types.STAGING}, + KeepUntrackedFiles: isTracked, + }) return nil } @@ -281,11 +289,17 @@ func (self *StagingController) applySelection(reverse bool) error { } func (self *StagingController) EditHunkAndRefresh() error { + file := self.c.Contexts().Files.FileTreeViewModel.GetSelectedFile() + isTracked := file != nil && file.Tracked + if err := self.editHunk(); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES, types.STAGING}, + KeepUntrackedFiles: isTracked, + }) return nil } diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 8092ee36ee9..93af05c38f0 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -44,4 +44,11 @@ type RefreshOptions struct { // keeps the selection index the same. Useful after checking out a detached // head, and selecting index 0. KeepBranchSelectionIndex bool + + // When true, skip enumerating untracked files during the files refresh + // and preserve untracked files from the previous model state instead. + // This is a performance optimization for operations that only affect + // tracked files (e.g. staging hunks), avoiding a costly + // git status --untracked-files=all in large repos. + KeepUntrackedFiles bool }