From 706a6c047487f8107874f46724a62cb41e121bd5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 15:36:25 +0100 Subject: [PATCH 1/8] Cleanup: use oscommands.RemoveFile rather than plain os.Remove This makes it mockable for tests, and is consistent with other uses in this file. --- pkg/commands/git_commands/working_tree.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 7aafe3655..48c20a690 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -219,8 +219,7 @@ func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error { ) for _, path := range untrackedFilePaths { - err := os.Remove(path) - if err != nil { + if err := self.os.RemoveFile(path); err != nil { return err } } From 4a8ab6f64e61af78ece5ecdb58e08f5cb5f3d50e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 16:42:00 +0100 Subject: [PATCH 2/8] Cleanup: simplify by using lo.FilterMap instead of separate calls to Filter and Map --- pkg/gui/filetree/node.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go index 5d9cefcd4..f6e343408 100644 --- a/pkg/gui/filetree/node.go +++ b/pkg/gui/filetree/node.go @@ -324,12 +324,8 @@ func (self *Node[T]) GetPathsMatching(predicate func(*Node[T]) bool) []string { } func (self *Node[T]) GetFilePathsMatching(predicate func(*T) bool) []string { - matchingFileNodes := lo.Filter(self.GetLeaves(), func(node *Node[T], _ int) bool { - return predicate(node.File) - }) - - return lo.Map(matchingFileNodes, func(node *Node[T], _ int) string { - return node.GetPath() + return lo.FilterMap(self.GetLeaves(), func(node *Node[T], _ int) (string, bool) { + return node.GetPath(), predicate(node.File) }) } From 4aa455e4ebf3e1cc067880e46d2c8f957204e9c5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 16:12:14 +0100 Subject: [PATCH 3/8] Cleanup: better check for which files are removed Previously it would only check that *if* removeFile was called, the passed argument was the expected one; but it didn't check whether it was called at all. Improve this by recording the file names that are removed, and checking them at the end of each scenario. This is going to be even more important for the tests that we are about to add in the next commit, because for those there can be several calls to removeFile in a single scenario. --- .../git_commands/working_tree_test.go | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 712b08ca1..3a6a53cbd 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -72,11 +72,12 @@ func TestWorkingTreeUnstageFile(t *testing.T) { // when the 'what' is what matters func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { type scenario struct { - testName string - file *models.File - removeFile func(string) error - runner *oscommands.FakeCmdObjRunner - expectedError string + testName string + file *models.File + removedFileErr error + runner *oscommands.FakeCmdObjRunner + expectedError string + expectedRemovedFiles []string } scenarios := []scenario{ @@ -86,7 +87,6 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Path: "test", HasStagedChanges: true, }, - removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", errors.New("error")), expectedError: "error", @@ -98,11 +98,10 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Tracked: false, Added: true, }, - removeFile: func(string) error { - return errors.New("an error occurred when removing file") - }, - runner: oscommands.NewFakeRunner(t), - expectedError: "an error occurred when removing file", + removedFileErr: errors.New("an error occurred when removing file"), + runner: oscommands.NewFakeRunner(t), + expectedError: "an error occurred when removing file", + expectedRemovedFiles: []string{"test"}, }, { testName: "An error occurred with checkout", @@ -111,7 +110,6 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Tracked: true, HasStagedChanges: false, }, - removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", errors.New("error")), expectedError: "error", @@ -123,10 +121,8 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Tracked: true, HasStagedChanges: false, }, - removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), - expectedError: "", }, { testName: "Reset and checkout staged changes", @@ -135,11 +131,9 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Tracked: true, HasStagedChanges: true, }, - removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), - expectedError: "", }, { testName: "Reset and checkout merge conflicts", @@ -148,11 +142,9 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Tracked: true, HasMergeConflicts: true, }, - removeFile: func(string) error { return nil }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), - expectedError: "", }, { testName: "Reset and remove", @@ -162,13 +154,9 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Added: true, HasStagedChanges: true, }, - removeFile: func(filename string) error { - assert.Equal(t, "test", filename) - return nil - }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil), - expectedError: "", + expectedRemovedFiles: []string{"test"}, }, { testName: "Remove only", @@ -178,18 +166,19 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { Added: true, HasStagedChanges: false, }, - removeFile: func(filename string) error { - assert.Equal(t, "test", filename) - return nil - }, - runner: oscommands.NewFakeRunner(t), - expectedError: "", + runner: oscommands.NewFakeRunner(t), + expectedRemovedFiles: []string{"test"}, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: s.removeFile}) + var removedFiles []string + removeFile := func(path string) error { + removedFiles = append(removedFiles, path) + return s.removedFileErr + } + instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) err := instance.DiscardAllFileChanges(s.file) if s.expectedError == "" { @@ -197,6 +186,7 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { } else { assert.Equal(t, s.expectedError, err.Error()) } + assert.Equal(t, s.expectedRemovedFiles, removedFiles) s.runner.CheckForMissingCalls() }) } From 5b829a6721fbdfd9bd304eecceedea5487c6a5be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 15:48:22 +0100 Subject: [PATCH 4/8] Add unit tests for WorkingTreeDiscard{All,Unstaged}DirChanges We have integration tests for this functionality, but those only test the behavior, not the performance. In these unit tests you can see that we make individual calls to git checkout and git reset for each file, which is very slow when there are lots of files. --- .../git_commands/working_tree_test.go | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 3a6a53cbd..f163b602b 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -472,6 +473,165 @@ func TestWorkingTreeDiscardUnstagedFileChanges(t *testing.T) { } } +// testNode implements IFileNode for unit tests. +type testNode struct { + files []*models.File // all leaf files under this node + path string + file *models.File // non-nil only for file nodes +} + +func (n *testNode) ForEachFile(cb func(*models.File) error) error { + for _, f := range n.files { + if err := cb(f); err != nil { + return err + } + } + return nil +} + +func (n *testNode) GetFilePathsMatching(test func(*models.File) bool) []string { + return lo.FilterMap(n.files, func(f *models.File, _ int) (string, bool) { + return f.Path, test(f) + }) +} + +func (n *testNode) GetPath() string { return n.path } +func (n *testNode) GetFile() *models.File { return n.file } + +func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { + type scenario struct { + testName string + node *testNode + runner *oscommands.FakeCmdObjRunner + expectedRemovedFiles []string + } + + scenarios := []scenario{ + { + testName: "multiple tracked files make individual checkout calls", + node: &testNode{ + files: []*models.File{ + {Path: "a.txt", Tracked: true}, + {Path: "b.txt", Tracked: true}, + {Path: "c.txt", Tracked: true}, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"checkout", "--", "a.txt"}, "", nil). + ExpectGitArgs([]string{"checkout", "--", "b.txt"}, "", nil). + ExpectGitArgs([]string{"checkout", "--", "c.txt"}, "", nil), + }, + { + testName: "staged files each make an individual reset then checkout", + node: &testNode{ + files: []*models.File{ + {Path: "a.txt", Tracked: true, HasStagedChanges: true}, + {Path: "b.txt", Tracked: true, HasStagedChanges: true}, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"reset", "--", "a.txt"}, "", nil). + ExpectGitArgs([]string{"checkout", "--", "a.txt"}, "", nil). + ExpectGitArgs([]string{"reset", "--", "b.txt"}, "", nil). + ExpectGitArgs([]string{"checkout", "--", "b.txt"}, "", nil), + }, + { + testName: "added files with no staged changes are removed from disk without any git call", + node: &testNode{ + files: []*models.File{ + {Path: "new1.txt", Added: true}, + {Path: "new2.txt", Added: true}, + }, + }, + runner: oscommands.NewFakeRunner(t), + expectedRemovedFiles: []string{"new1.txt", "new2.txt"}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + var removedFiles []string + removeFile := func(path string) error { + removedFiles = append(removedFiles, path) + return nil + } + instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) + err := instance.DiscardAllDirChanges(s.node) + assert.NoError(t, err) + assert.Equal(t, s.expectedRemovedFiles, removedFiles) + s.runner.CheckForMissingCalls() + }) + } +} + +func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { + type scenario struct { + testName string + node *testNode + runner *oscommands.FakeCmdObjRunner + expectedRemovedFiles []string + } + + scenarios := []scenario{ + { + testName: "directory node: uses directory path for checkout", + node: &testNode{ + path: "dir", + files: []*models.File{ + {Path: "dir/tracked1.txt", Tracked: true}, + {Path: "dir/tracked2.txt", Tracked: true}, + {Path: "dir/new.txt", Tracked: false}, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"checkout", "--", "dir"}, "", nil), + expectedRemovedFiles: []string{"dir/new.txt"}, + }, + { + testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is removed along with untracked files", + node: &testNode{ + path: "dir", + files: []*models.File{ + {Path: "dir/staged-new1.txt", Tracked: false, Added: true, HasStagedChanges: true}, + {Path: "dir/staged-new2.txt", Tracked: false, Added: true, HasStagedChanges: true}, + {Path: "dir/untracked.txt", Tracked: false, Added: true, HasStagedChanges: false}, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"checkout", "--", "dir"}, "", nil), + // All files are removed because the predicate of GetFilePathsMatching in + // RemoveUntrackedDirFiles is just !Tracked. git checkout -- dir then restores the + // staged file from the index. This is a bit wasteful, and we'll improve it at the end + // of this branch. + expectedRemovedFiles: []string{"dir/staged-new1.txt", "dir/staged-new2.txt", "dir/untracked.txt"}, + }, + { + testName: "file node: added and unstaged file is removed from disk", + node: &testNode{ + path: "new.txt", + files: []*models.File{{Path: "new.txt", Added: true}}, + file: &models.File{Path: "new.txt", Added: true, HasStagedChanges: false}, + }, + runner: oscommands.NewFakeRunner(t), + expectedRemovedFiles: []string{"new.txt"}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + var removedFiles []string + removeFile := func(path string) error { + removedFiles = append(removedFiles, path) + return nil + } + instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) + assert.NoError(t, instance.DiscardUnstagedDirChanges(s.node)) + s.runner.CheckForMissingCalls() + assert.Equal(t, s.expectedRemovedFiles, removedFiles) + }) + } +} + func TestWorkingTreeDiscardAnyUnstagedFileChanges(t *testing.T) { type scenario struct { testName string From f987b35a9e2b573f995ce0f111b8b2db8be5e48d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 18:10:18 +0100 Subject: [PATCH 5/8] Add integration tests for discarding dir changes when filtering by path When discarding a directory, we only want to discard those files that are visible in the current filter view. The tests show that this already works correctly for discarding all changes, but it doesn't for discarding only unstaged changes: in this case, untracked files are handled correctly, but changes to tracked files are discarded without respecting the filter. --- .../discard_all_dir_changes_when_filtering.go | 69 ++++++++++++++++ ...ard_unstaged_dir_changes_when_filtering.go | 79 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 3 files changed, 150 insertions(+) create mode 100644 pkg/integration/tests/file/discard_all_dir_changes_when_filtering.go create mode 100644 pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go diff --git a/pkg/integration/tests/file/discard_all_dir_changes_when_filtering.go b/pkg/integration/tests/file/discard_all_dir_changes_when_filtering.go new file mode 100644 index 000000000..6b3ae906e --- /dev/null +++ b/pkg/integration/tests/file/discard_all_dir_changes_when_filtering.go @@ -0,0 +1,69 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardAllDirChangesWhenFiltering = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding changes in a directory when filtering by path", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + }, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateFileAndAdd("dir/file-one", "original content\n") + shell.CreateFileAndAdd("dir/file-two", "original content\n") + + shell.Commit("first commit") + + shell.UpdateFileAndAdd("dir/file-one", "original content\nnew content\n") + shell.UpdateFileAndAdd("dir/file-two", "original content\nnew content\n") + shell.UpdateFile("dir/file-one", "original content\nnew content\neven newer content\n") + shell.UpdateFile("dir/file-two", "original content\nnew content\neven newer content\n") + + shell.CreateFile("dir/unstaged-file-one", "unstaged file") + shell.CreateFile("dir/unstaged-file-two", "unstaged file") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" MM file-one"), + Equals(" MM file-two"), + Equals(" ?? unstaged-file-one"), + Equals(" ?? unstaged-file-two"), + ). + Press(keys.Universal.StartSearch). + Tap(func() { + t.ExpectSearch(). + Type("one"). + Confirm() + }). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" MM file-one"), + Equals(" ?? unstaged-file-one"), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard all changes")). + Confirm() + }). + Press(keys.Universal.Return). // Cancel filtering + Lines( + Equals("▼ dir").IsSelected(), + Equals(" MM file-two"), + Equals(" ?? unstaged-file-two"), + ) + + t.FileSystem().FileContent("dir/file-one", Equals("original content\n")) + t.FileSystem().FileContent("dir/file-two", Equals("original content\nnew content\neven newer content\n")) + t.FileSystem().PathNotPresent("dir/unstaged-file-one") + t.FileSystem().FileContent("dir/unstaged-file-two", Equals("unstaged file")) + }, +}) diff --git a/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go b/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go new file mode 100644 index 000000000..4996f699e --- /dev/null +++ b/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go @@ -0,0 +1,79 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardUnstagedDirChangesWhenFiltering = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding unstaged changes in a directory when filtering by path", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + }, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateFileAndAdd("dir/file-one", "original content\n") + shell.CreateFileAndAdd("dir/file-two", "original content\n") + + shell.Commit("first commit") + + shell.UpdateFileAndAdd("dir/file-one", "original content\nnew content\n") + shell.UpdateFileAndAdd("dir/file-two", "original content\nnew content\n") + shell.UpdateFile("dir/file-one", "original content\nnew content\neven newer content\n") + shell.UpdateFile("dir/file-two", "original content\nnew content\neven newer content\n") + + shell.CreateFile("dir/unstaged-file-one", "unstaged file") + shell.CreateFile("dir/unstaged-file-two", "unstaged file") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" MM file-one"), + Equals(" MM file-two"), + Equals(" ?? unstaged-file-one"), + Equals(" ?? unstaged-file-two"), + ). + Press(keys.Universal.StartSearch). + Tap(func() { + t.ExpectSearch(). + Type("one"). + Confirm() + }). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" MM file-one"), + Equals(" ?? unstaged-file-one"), + ). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Discard changes")). + Select(Contains("Discard unstaged changes")). + Confirm() + }). + Press(keys.Universal.Return). // Cancel filtering + Lines( + /* EXPECTED: + Equals("▼ dir").IsSelected(), + Equals(" M file-one"), + Equals(" MM file-two"), + Equals(" ?? unstaged-file-two"), + ACTUAL: */ + Equals("▼ dir").IsSelected(), + Equals(" M file-one"), + Equals(" M file-two"), + Equals(" ?? unstaged-file-two"), + ) + + t.FileSystem().FileContent("dir/file-one", Equals("original content\nnew content\n")) + /* EXPECTED: + t.FileSystem().FileContent("dir/file-two", Equals("original content\nnew content\neven newer content\n")) + ACTUAL: */ + t.FileSystem().FileContent("dir/file-two", Equals("original content\nnew content\n")) + t.FileSystem().PathNotPresent("dir/unstaged-file-one") + t.FileSystem().FileContent("dir/unstaged-file-two", Equals("unstaged file")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 85183406b..3d8edfb56 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -215,9 +215,11 @@ var tests = []*components.IntegrationTest{ file.CopyMenu, file.DirWithUntrackedFile, file.DiscardAllDirChanges, + file.DiscardAllDirChangesWhenFiltering, file.DiscardRangeSelect, file.DiscardStagedChanges, file.DiscardUnstagedDirChanges, + file.DiscardUnstagedDirChangesWhenFiltering, file.DiscardUnstagedFileChanges, file.DiscardUnstagedRangeSelect, file.DiscardVariousChanges, From e434f5b5e937c379ca67152f9c18d9efce44d746 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 17:23:02 +0100 Subject: [PATCH 6/8] Add runGitCmdOnPaths utility Useful when we need to call git with potentially tons of arguments that might exceed the OS' command-line length limit. --- .../git_commands/git_command_builder.go | 29 +++++++++++++ .../git_commands/git_command_builder_test.go | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index 5e9c3b258..30496f453 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -2,6 +2,8 @@ package git_commands import ( "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) // convenience struct for building git commands. Especially useful when @@ -106,3 +108,30 @@ func (self *GitCommandBuilder) ToArgv() []string { func (self *GitCommandBuilder) ToString() string { return strings.Join(self.ToArgv(), " ") } + +// runGitCmdOnPaths runs `git -- `, splitting into +// multiple calls if needed to stay under the OS command-line length limit. +// Windows CreateProcess has a ~32 KB limit; we use 30 KB as a safe threshold. +func runGitCmdOnPaths(subcommand string, paths []string, cmd oscommands.ICmdObjBuilder) error { + const maxArgBytes = 30_000 + + start := 0 + for start < len(paths) { + end := start + total := 0 + for end < len(paths) { + total += len(paths[end]) + 1 // +1 for the separating space + if total > maxArgBytes && end > start { + break + } + end++ + } + if err := cmd.New(NewGitCmd(subcommand).Arg("--"). + Arg(paths[start:end]...). + ToArgv()).Run(); err != nil { + return err + } + start = end + } + return nil +} diff --git a/pkg/commands/git_commands/git_command_builder_test.go b/pkg/commands/git_commands/git_command_builder_test.go index 69d41854c..a839afb9a 100644 --- a/pkg/commands/git_commands/git_command_builder_test.go +++ b/pkg/commands/git_commands/git_command_builder_test.go @@ -1,8 +1,10 @@ package git_commands import ( + "strings" "testing" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/stretchr/testify/assert" ) @@ -54,3 +56,44 @@ func TestGitCommandBuilder(t *testing.T) { assert.Equal(t, s.input, s.expected) } } + +func TestRunGitCmdOnPaths(t *testing.T) { + // Each path is 9000 bytes. Three fit within the 30 KB limit (27001 bytes + // including spaces), four do not (36002 bytes), so a four-path slice must + // be split into two calls of three and one. + longPath := func(ch string) string { return strings.Repeat(ch, 9_000) } + p1, p2, p3, p4 := longPath("a"), longPath("b"), longPath("c"), longPath("d") + + scenarios := []struct { + name string + paths []string + runner *oscommands.FakeCmdObjRunner + }{ + { + name: "empty list makes no calls", + paths: []string{}, + runner: oscommands.NewFakeRunner(t), + }, + { + name: "paths that fit in one batch make a single call", + paths: []string{p1, p2, p3}, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(append([]string{"checkout", "--"}, p1, p2, p3), "", nil), + }, + { + name: "paths that exceed the limit are split across multiple calls", + paths: []string{p1, p2, p3, p4}, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(append([]string{"checkout", "--"}, p1, p2, p3), "", nil). + ExpectGitArgs(append([]string{"checkout", "--"}, p4), "", nil), + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + cmd := oscommands.NewDummyCmdObjBuilder(s.runner) + assert.NoError(t, runGitCmdOnPaths("checkout", s.paths, cmd)) + s.runner.CheckForMissingCalls() + }) + } +} From ad3140081899c1111cc97339e34dc0f6a9b20100 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 14:14:13 +0100 Subject: [PATCH 7/8] Improve performance of discarding changes in large directories Previously it would iterate over all changed files and call git checkout or git reset for each one, which can take forever if there are hundreds or thousands of files. Now it batches these into a single command if possible (taking care of still passing the individual path names to the git call rather than just the directory, which is necessary for making it work correctly when filtering -- this was actually broken for the "Discard unstaged changes" command, which is fixed here). --- pkg/commands/git_commands/working_tree.go | 72 +++++++++++++++++-- .../git_commands/working_tree_test.go | 33 ++++----- ...ard_unstaged_dir_changes_when_filtering.go | 9 --- 3 files changed, 82 insertions(+), 32 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 48c20a690..126fe1048 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -185,8 +185,64 @@ type IFileNode interface { } func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error { - // this could be more efficient but we would need to handle all the edge cases - return node.ForEachFile(self.DiscardAllFileChanges) + // Collect files into buckets so we can batch git calls where possible. + var specialFiles []*models.File // renames, AA, DU — handled individually + var filesToReset []string // need `git reset` first (staged or conflicted) + var filesToCheckout []string // need `git checkout` (after optional reset) + var filesToRemove []string // added files to delete from disk + + _ = node.ForEachFile(func(file *models.File) error { + // Renames and certain merge-conflict statuses need per-file logic. + if file.IsRename() || file.ShortStatus == "AA" || file.ShortStatus == "DU" { + specialFiles = append(specialFiles, file) + return nil + } + + if file.HasStagedChanges || file.HasMergeConflicts { + filesToReset = append(filesToReset, file.Path) + // DD and AU are done after the reset; no checkout or remove needed. + if file.ShortStatus == "DD" || file.ShortStatus == "AU" { + return nil + } + if file.Added { + filesToRemove = append(filesToRemove, file.Path) + } else { + filesToCheckout = append(filesToCheckout, file.Path) + } + return nil + } + + // No staged changes below this point. + if file.ShortStatus == "DD" || file.ShortStatus == "AU" { + return nil + } + + if file.Added { + filesToRemove = append(filesToRemove, file.Path) + return nil + } + + filesToCheckout = append(filesToCheckout, file.Path) + return nil + }) + + for _, file := range specialFiles { + if err := self.DiscardAllFileChanges(file); err != nil { + return err + } + } + + if err := runGitCmdOnPaths("reset", filesToReset, self.cmd); err != nil { + return err + } + + for _, path := range filesToRemove { + if err := self.os.RemoveFile(path); err != nil { + return err + } + } + + return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) } func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error { @@ -196,8 +252,14 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error return err } - cmdArgs := NewGitCmd("checkout").Arg("--", node.GetPath()).ToArgv() - if err := self.cmd.New(cmdArgs).Run(); err != nil { + // Use specific file paths rather than the directory path, so that an + // active filter (e.g. from pressing `/`) only discards visible files. + // Include staged files: a file that is staged but also has additional + // unstaged changes (AM status) needs checkout to discard those changes. + trackedPaths := node.GetFilePathsMatching(func(f *models.File) bool { + return f.GetIsTracked() || f.GetHasStagedChanges() + }) + if err := runGitCmdOnPaths("checkout", trackedPaths, self.cmd); err != nil { return err } } else { @@ -215,7 +277,7 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error { untrackedFilePaths := node.GetFilePathsMatching( - func(file *models.File) bool { return !file.GetIsTracked() }, + func(file *models.File) bool { return !file.GetIsTracked() && !file.GetHasStagedChanges() }, ) for _, path := range untrackedFilePaths { diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index f163b602b..5ba83ebc1 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -508,7 +508,7 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { scenarios := []scenario{ { - testName: "multiple tracked files make individual checkout calls", + testName: "multiple regular tracked files batched into a single checkout call", node: &testNode{ files: []*models.File{ {Path: "a.txt", Tracked: true}, @@ -517,12 +517,10 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { }, }, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"checkout", "--", "a.txt"}, "", nil). - ExpectGitArgs([]string{"checkout", "--", "b.txt"}, "", nil). - ExpectGitArgs([]string{"checkout", "--", "c.txt"}, "", nil), + ExpectGitArgs([]string{"checkout", "--", "a.txt", "b.txt", "c.txt"}, "", nil), }, { - testName: "staged files each make an individual reset then checkout", + testName: "staged files batched into a single reset then a single checkout", node: &testNode{ files: []*models.File{ {Path: "a.txt", Tracked: true, HasStagedChanges: true}, @@ -530,10 +528,8 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { }, }, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"reset", "--", "a.txt"}, "", nil). - ExpectGitArgs([]string{"checkout", "--", "a.txt"}, "", nil). - ExpectGitArgs([]string{"reset", "--", "b.txt"}, "", nil). - ExpectGitArgs([]string{"checkout", "--", "b.txt"}, "", nil), + ExpectGitArgs([]string{"reset", "--", "a.txt", "b.txt"}, "", nil). + ExpectGitArgs([]string{"checkout", "--", "a.txt", "b.txt"}, "", nil), }, { testName: "added files with no staged changes are removed from disk without any git call", @@ -574,7 +570,7 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { scenarios := []scenario{ { - testName: "directory node: uses directory path for checkout", + testName: "directory node: removes untracked files and checks out tracked files by path, not by directory", node: &testNode{ path: "dir", files: []*models.File{ @@ -583,27 +579,28 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { {Path: "dir/new.txt", Tracked: false}, }, }, + // Must checkout the individual files, not "dir" — otherwise a filter would be ignored. runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"checkout", "--", "dir"}, "", nil), + ExpectGitArgs([]string{"checkout", "--", "dir/tracked1.txt", "dir/tracked2.txt"}, "", nil), expectedRemovedFiles: []string{"dir/new.txt"}, }, { - testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is removed along with untracked files", + testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is left alone; purely untracked file is removed", node: &testNode{ path: "dir", files: []*models.File{ + // Staged new files: not removed from disk (RemoveUntrackedDirFiles + // skips staged files), but checked out in case it also has + // unstaged changes on top (AM status). {Path: "dir/staged-new1.txt", Tracked: false, Added: true, HasStagedChanges: true}, {Path: "dir/staged-new2.txt", Tracked: false, Added: true, HasStagedChanges: true}, + // Purely untracked file: removed from disk, not checked out. {Path: "dir/untracked.txt", Tracked: false, Added: true, HasStagedChanges: false}, }, }, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"checkout", "--", "dir"}, "", nil), - // All files are removed because the predicate of GetFilePathsMatching in - // RemoveUntrackedDirFiles is just !Tracked. git checkout -- dir then restores the - // staged file from the index. This is a bit wasteful, and we'll improve it at the end - // of this branch. - expectedRemovedFiles: []string{"dir/staged-new1.txt", "dir/staged-new2.txt", "dir/untracked.txt"}, + ExpectGitArgs([]string{"checkout", "--", "dir/staged-new1.txt", "dir/staged-new2.txt"}, "", nil), + expectedRemovedFiles: []string{"dir/untracked.txt"}, }, { testName: "file node: added and unstaged file is removed from disk", diff --git a/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go b/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go index 4996f699e..97dbb7a32 100644 --- a/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go +++ b/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go @@ -56,23 +56,14 @@ var DiscardUnstagedDirChangesWhenFiltering = NewIntegrationTest(NewIntegrationTe }). Press(keys.Universal.Return). // Cancel filtering Lines( - /* EXPECTED: Equals("▼ dir").IsSelected(), Equals(" M file-one"), Equals(" MM file-two"), Equals(" ?? unstaged-file-two"), - ACTUAL: */ - Equals("▼ dir").IsSelected(), - Equals(" M file-one"), - Equals(" M file-two"), - Equals(" ?? unstaged-file-two"), ) t.FileSystem().FileContent("dir/file-one", Equals("original content\nnew content\n")) - /* EXPECTED: t.FileSystem().FileContent("dir/file-two", Equals("original content\nnew content\neven newer content\n")) - ACTUAL: */ - t.FileSystem().FileContent("dir/file-two", Equals("original content\nnew content\n")) t.FileSystem().PathNotPresent("dir/unstaged-file-one") t.FileSystem().FileContent("dir/unstaged-file-two", Equals("unstaged file")) }, From 4d46f5af94297fa60880b2cda3d8c4e744168226 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 19:03:53 +0100 Subject: [PATCH 8/8] Improve performance for discarding range selection of many files This is probably the less severe case, but it could still be an issue for people who have many modified top-level files they want to discard, and have showRootItemInFileTree set to false; they could select all those files by pressing 'v' and '>'. --- pkg/commands/git_commands/working_tree.go | 96 ++++++++++--------- .../git_commands/working_tree_test.go | 81 ++++++++++++---- pkg/gui/controllers/files_controller.go | 14 ++- 3 files changed, 117 insertions(+), 74 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 126fe1048..c01a582ff 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -184,47 +184,49 @@ type IFileNode interface { GetFile() *models.File } -func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error { +func (self *WorkingTreeCommands) DiscardAllDirChanges(nodes []IFileNode) error { // Collect files into buckets so we can batch git calls where possible. var specialFiles []*models.File // renames, AA, DU — handled individually var filesToReset []string // need `git reset` first (staged or conflicted) var filesToCheckout []string // need `git checkout` (after optional reset) var filesToRemove []string // added files to delete from disk - _ = node.ForEachFile(func(file *models.File) error { - // Renames and certain merge-conflict statuses need per-file logic. - if file.IsRename() || file.ShortStatus == "AA" || file.ShortStatus == "DU" { - specialFiles = append(specialFiles, file) - return nil - } + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + // Renames and certain merge-conflict statuses need per-file logic. + if file.IsRename() || file.ShortStatus == "AA" || file.ShortStatus == "DU" { + specialFiles = append(specialFiles, file) + return nil + } - if file.HasStagedChanges || file.HasMergeConflicts { - filesToReset = append(filesToReset, file.Path) - // DD and AU are done after the reset; no checkout or remove needed. + if file.HasStagedChanges || file.HasMergeConflicts { + filesToReset = append(filesToReset, file.Path) + // DD and AU are done after the reset; no checkout or remove needed. + if file.ShortStatus == "DD" || file.ShortStatus == "AU" { + return nil + } + if file.Added { + filesToRemove = append(filesToRemove, file.Path) + } else { + filesToCheckout = append(filesToCheckout, file.Path) + } + return nil + } + + // No staged changes below this point. if file.ShortStatus == "DD" || file.ShortStatus == "AU" { return nil } + if file.Added { filesToRemove = append(filesToRemove, file.Path) - } else { - filesToCheckout = append(filesToCheckout, file.Path) + return nil } - return nil - } - // No staged changes below this point. - if file.ShortStatus == "DD" || file.ShortStatus == "AU" { + filesToCheckout = append(filesToCheckout, file.Path) return nil - } - - if file.Added { - filesToRemove = append(filesToRemove, file.Path) - return nil - } - - filesToCheckout = append(filesToCheckout, file.Path) - return nil - }) + }) + } for _, file := range specialFiles { if err := self.DiscardAllFileChanges(file); err != nil { @@ -245,34 +247,34 @@ func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error { return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) } -func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error { - file := node.GetFile() - if file == nil { - if err := self.RemoveUntrackedDirFiles(node); err != nil { - return err - } +func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(nodes []IFileNode) error { + // Collect files into buckets so we can batch git calls where possible. + // Use specific file paths rather than directory paths, so that an active + // filter (e.g. from pressing `/`) only discards visible files. + var filesToRemove []string // purely untracked: remove from disk + var filesToCheckout []string // tracked or staged: restore via checkout - // Use specific file paths rather than the directory path, so that an - // active filter (e.g. from pressing `/`) only discards visible files. - // Include staged files: a file that is staged but also has additional - // unstaged changes (AM status) needs checkout to discard those changes. - trackedPaths := node.GetFilePathsMatching(func(f *models.File) bool { - return f.GetIsTracked() || f.GetHasStagedChanges() + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + if !file.Tracked && !file.HasStagedChanges { + filesToRemove = append(filesToRemove, file.Path) + } else { + // Include staged files: a file that is staged but also has + // additional unstaged changes (AM status) needs checkout to + // discard those changes. + filesToCheckout = append(filesToCheckout, file.Path) + } + return nil }) - if err := runGitCmdOnPaths("checkout", trackedPaths, self.cmd); err != nil { - return err - } - } else { - if file.Added && !file.HasStagedChanges { - return self.os.RemoveFile(file.Path) - } + } - if err := self.DiscardUnstagedFileChanges(file); err != nil { + for _, path := range filesToRemove { + if err := self.os.RemoveFile(path); err != nil { return err } } - return nil + return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) } func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error { diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 5ba83ebc1..ce9d7971d 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -501,7 +501,7 @@ func (n *testNode) GetFile() *models.File { return n.file } func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { type scenario struct { testName string - node *testNode + nodes []IFileNode runner *oscommands.FakeCmdObjRunner expectedRemovedFiles []string } @@ -509,39 +509,61 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { scenarios := []scenario{ { testName: "multiple regular tracked files batched into a single checkout call", - node: &testNode{ + nodes: []IFileNode{&testNode{ files: []*models.File{ {Path: "a.txt", Tracked: true}, {Path: "b.txt", Tracked: true}, {Path: "c.txt", Tracked: true}, }, - }, + }}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "a.txt", "b.txt", "c.txt"}, "", nil), }, { testName: "staged files batched into a single reset then a single checkout", - node: &testNode{ + nodes: []IFileNode{&testNode{ files: []*models.File{ {Path: "a.txt", Tracked: true, HasStagedChanges: true}, {Path: "b.txt", Tracked: true, HasStagedChanges: true}, }, - }, + }}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "a.txt", "b.txt"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "a.txt", "b.txt"}, "", nil), }, { testName: "added files with no staged changes are removed from disk without any git call", - node: &testNode{ + nodes: []IFileNode{&testNode{ files: []*models.File{ {Path: "new1.txt", Added: true}, {Path: "new2.txt", Added: true}, }, - }, + }}, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"new1.txt", "new2.txt"}, }, + { + testName: "files from multiple nodes are batched into a single git call", + nodes: []IFileNode{ + &testNode{ + path: "dir1", + files: []*models.File{ + {Path: "dir1/a.txt", Tracked: true}, + {Path: "dir1/b.txt", Added: true}, + }, + }, + &testNode{ + path: "dir2", + files: []*models.File{ + {Path: "dir2/c.txt", Tracked: true}, + {Path: "dir2/d.txt", Added: true}, + }, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"checkout", "--", "dir1/a.txt", "dir2/c.txt"}, "", nil), + expectedRemovedFiles: []string{"dir1/b.txt", "dir2/d.txt"}, + }, } for _, s := range scenarios { @@ -552,7 +574,7 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { return nil } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) - err := instance.DiscardAllDirChanges(s.node) + err := instance.DiscardAllDirChanges(s.nodes) assert.NoError(t, err) assert.Equal(t, s.expectedRemovedFiles, removedFiles) s.runner.CheckForMissingCalls() @@ -563,7 +585,7 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { type scenario struct { testName string - node *testNode + nodes []IFileNode runner *oscommands.FakeCmdObjRunner expectedRemovedFiles []string } @@ -571,14 +593,14 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { scenarios := []scenario{ { testName: "directory node: removes untracked files and checks out tracked files by path, not by directory", - node: &testNode{ + nodes: []IFileNode{&testNode{ path: "dir", files: []*models.File{ {Path: "dir/tracked1.txt", Tracked: true}, {Path: "dir/tracked2.txt", Tracked: true}, {Path: "dir/new.txt", Tracked: false}, }, - }, + }}, // Must checkout the individual files, not "dir" — otherwise a filter would be ignored. runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "dir/tracked1.txt", "dir/tracked2.txt"}, "", nil), @@ -586,32 +608,53 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { }, { testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is left alone; purely untracked file is removed", - node: &testNode{ + nodes: []IFileNode{&testNode{ path: "dir", files: []*models.File{ - // Staged new files: not removed from disk (RemoveUntrackedDirFiles - // skips staged files), but checked out in case it also has - // unstaged changes on top (AM status). + // Staged new files: not removed from disk, but checked out in + // case they also have unstaged changes on top (AM status). {Path: "dir/staged-new1.txt", Tracked: false, Added: true, HasStagedChanges: true}, {Path: "dir/staged-new2.txt", Tracked: false, Added: true, HasStagedChanges: true}, // Purely untracked file: removed from disk, not checked out. {Path: "dir/untracked.txt", Tracked: false, Added: true, HasStagedChanges: false}, }, - }, + }}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "dir/staged-new1.txt", "dir/staged-new2.txt"}, "", nil), expectedRemovedFiles: []string{"dir/untracked.txt"}, }, { testName: "file node: added and unstaged file is removed from disk", - node: &testNode{ + nodes: []IFileNode{&testNode{ path: "new.txt", files: []*models.File{{Path: "new.txt", Added: true}}, file: &models.File{Path: "new.txt", Added: true, HasStagedChanges: false}, - }, + }}, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"new.txt"}, }, + { + testName: "files from multiple nodes are batched into a single checkout call", + nodes: []IFileNode{ + &testNode{ + path: "dir1", + files: []*models.File{ + {Path: "dir1/tracked.txt", Tracked: true}, + {Path: "dir1/untracked.txt", Tracked: false}, + }, + }, + &testNode{ + path: "dir2", + files: []*models.File{ + {Path: "dir2/tracked.txt", Tracked: true}, + {Path: "dir2/untracked.txt", Tracked: false}, + }, + }, + }, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"checkout", "--", "dir1/tracked.txt", "dir2/tracked.txt"}, "", nil), + expectedRemovedFiles: []string{"dir1/untracked.txt", "dir2/untracked.txt"}, + }, } for _, s := range scenarios { @@ -622,7 +665,7 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { return nil } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) - assert.NoError(t, instance.DiscardUnstagedDirChanges(s.node)) + assert.NoError(t, instance.DiscardUnstagedDirChanges(s.nodes)) s.runner.CheckForMissingCalls() assert.Equal(t, s.expectedRemovedFiles, removedFiles) }) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index acc615cce..6686a7e9d 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1496,10 +1496,9 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { defer self.context().CancelRangeSelect() } - for _, node := range selectedNodes { - if err := self.c.Git().WorkingTree.DiscardAllDirChanges(node); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardAllDirChanges(nodes); err != nil { + return err } self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) @@ -1523,10 +1522,9 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { defer self.context().CancelRangeSelect() } - for _, node := range selectedNodes { - if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(node); err != nil { - return err - } + nodes := lo.Map(selectedNodes, func(n *filetree.FileNode, _ int) git_commands.IFileNode { return n }) + if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(nodes); err != nil { + return err } self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})