Avoid enumerating untracked files when staging hunks of tracked files

We know that staging or unstaging a hunk in a tracked file doesn't have
influence on untracked files, so we can take them over from the previous state
of the model. This can be a performance improvement in repos with many untracked
files.
This commit is contained in:
Stefan Haller 2026-04-06 11:36:22 +02:00
parent cf491d1b5b
commit 38f35ae346
3 changed files with 34 additions and 7 deletions

View file

@ -169,7 +169,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) {
if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) { if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) {
fileWg.Add(1) fileWg.Add(1)
refresh("files", func() { refresh("files", func() {
_ = self.refreshFilesAndSubmodules() _ = self.refreshFilesAndSubmodules(options.TakeOverUntrackedFilesFromPreviousModel)
fileWg.Done() fileWg.Done()
}) })
} }
@ -539,7 +539,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
self.refreshStatus() self.refreshStatus()
} }
func (self *RefreshHelper) refreshFilesAndSubmodules() error { func (self *RefreshHelper) refreshFilesAndSubmodules(takeOverUntrackedFilesFromPreviousModel bool) error {
self.c.Mutexes().RefreshingFilesMutex.Lock() self.c.Mutexes().RefreshingFilesMutex.Lock()
self.c.State().SetIsRefreshingFiles(true) self.c.State().SetIsRefreshingFiles(true)
defer func() { defer func() {
@ -551,7 +551,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
return err return err
} }
if err := self.refreshStateFiles(); err != nil { if err := self.refreshStateFiles(takeOverUntrackedFilesFromPreviousModel); err != nil {
return err return err
} }
@ -564,7 +564,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
return nil return nil
} }
func (self *RefreshHelper) refreshStateFiles() error { func (self *RefreshHelper) refreshStateFiles(takeOverUntrackedFilesFromPreviousModel bool) error {
fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel
prevConflictFileCount := 0 prevConflictFileCount := 0
@ -599,8 +599,16 @@ func (self *RefreshHelper) refreshStateFiles() error {
} }
} }
var previousUntrackedFiles []*models.File
if takeOverUntrackedFilesFromPreviousModel {
previousUntrackedFiles = lo.Filter(self.c.Model().Files,
func(file *models.File, _ int) bool { return file.ShortStatus == "??" })
}
showUntracked := git_commands.ShowUntrackedModeAuto showUntracked := git_commands.ShowUntrackedModeAuto
if self.c.Contexts().Files.ForceShowUntracked() { if takeOverUntrackedFilesFromPreviousModel {
showUntracked = git_commands.ShowUntrackedModeOff
} else if self.c.Contexts().Files.ForceShowUntracked() {
showUntracked = git_commands.ShowUntrackedModeOn showUntracked = git_commands.ShowUntrackedModeOn
} }
files := self.c.Git().Loaders.FileLoader. files := self.c.Git().Loaders.FileLoader.
@ -608,6 +616,10 @@ func (self *RefreshHelper) refreshStateFiles() error {
ShowUntracked: showUntracked, ShowUntracked: showUntracked,
}) })
if takeOverUntrackedFilesFromPreviousModel {
files = append(files, previousUntrackedFiles...)
}
conflictFileCount := 0 conflictFileCount := 0
for _, file := range files { for _, file := range files {
if file.HasMergeConflicts { if file.HasMergeConflicts {

View file

@ -226,11 +226,16 @@ func (self *StagingController) DiscardSelection() error {
} }
func (self *StagingController) applySelectionAndRefresh(reverse bool) error { func (self *StagingController) applySelectionAndRefresh(reverse bool) error {
isTracked := self.c.Contexts().Files.GetSelectedFile().Tracked
if err := self.applySelection(reverse); err != nil { if err := self.applySelection(reverse); err != nil {
return err 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},
TakeOverUntrackedFilesFromPreviousModel: isTracked,
})
return nil return nil
} }
@ -281,11 +286,16 @@ func (self *StagingController) applySelection(reverse bool) error {
} }
func (self *StagingController) EditHunkAndRefresh() error { func (self *StagingController) EditHunkAndRefresh() error {
isTracked := self.c.Contexts().Files.GetSelectedFile().Tracked
if err := self.editHunk(); err != nil { if err := self.editHunk(); err != nil {
return err 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},
TakeOverUntrackedFilesFromPreviousModel: isTracked,
})
return nil return nil
} }

View file

@ -44,4 +44,9 @@ type RefreshOptions struct {
// keeps the selection index the same. Useful after checking out a detached // keeps the selection index the same. Useful after checking out a detached
// head, and selecting index 0. // head, and selecting index 0.
KeepBranchSelectionIndex bool KeepBranchSelectionIndex bool
// If true, call git status with --untracked-files=no to skip enumerating untracked files, and
// keep the ones we have in the model. Useful as a performance optimization when we know the
// untracked files can't have changed, e.g. after staging/unstaging hunks of tracked files.
TakeOverUntrackedFilesFromPreviousModel bool
} }