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() + }) + } +} diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 7aafe3655..c01a582ff 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -184,43 +184,106 @@ type IFileNode interface { GetFile() *models.File } -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) -} +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 -func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error { - file := node.GetFile() - if file == nil { - if err := self.RemoveUntrackedDirFiles(node); err != nil { - return err - } + 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 + } - cmdArgs := NewGitCmd("checkout").Arg("--", node.GetPath()).ToArgv() - if err := self.cmd.New(cmdArgs).Run(); err != nil { - return err - } - } else { - if file.Added && !file.HasStagedChanges { - return self.os.RemoveFile(file.Path) - } + 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 + } - if err := self.DiscardUnstagedFileChanges(file); err != 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 } } - return nil + 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(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 + + 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 + }) + } + + for _, path := range filesToRemove { + if err := self.os.RemoveFile(path); err != nil { + return err + } + } + + return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) } 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 { - err := os.Remove(path) - if err != nil { + if err := self.os.RemoveFile(path); err != nil { return err } } diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 712b08ca1..ce9d7971d 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" ) @@ -72,11 +73,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 +88,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 +99,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 +111,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 +122,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 +132,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 +143,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 +155,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 +167,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 +187,7 @@ func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { } else { assert.Equal(t, s.expectedError, err.Error()) } + assert.Equal(t, s.expectedRemovedFiles, removedFiles) s.runner.CheckForMissingCalls() }) } @@ -482,6 +473,205 @@ 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 + nodes []IFileNode + runner *oscommands.FakeCmdObjRunner + expectedRemovedFiles []string + } + + scenarios := []scenario{ + { + testName: "multiple regular tracked files batched into a single checkout call", + 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", + 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", + 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 { + 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.nodes) + assert.NoError(t, err) + assert.Equal(t, s.expectedRemovedFiles, removedFiles) + s.runner.CheckForMissingCalls() + }) + } +} + +func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { + type scenario struct { + testName string + nodes []IFileNode + runner *oscommands.FakeCmdObjRunner + expectedRemovedFiles []string + } + + scenarios := []scenario{ + { + testName: "directory node: removes untracked files and checks out tracked files by path, not by directory", + 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), + expectedRemovedFiles: []string{"dir/new.txt"}, + }, + { + testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is left alone; purely untracked file is removed", + nodes: []IFileNode{&testNode{ + path: "dir", + files: []*models.File{ + // 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", + 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 { + 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.nodes)) + s.runner.CheckForMissingCalls() + assert.Equal(t, s.expectedRemovedFiles, removedFiles) + }) + } +} + func TestWorkingTreeDiscardAnyUnstagedFileChanges(t *testing.T) { type scenario struct { testName string 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}}) 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) }) } 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..97dbb7a32 --- /dev/null +++ b/pkg/integration/tests/file/discard_unstaged_dir_changes_when_filtering.go @@ -0,0 +1,70 @@ +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( + Equals("▼ dir").IsSelected(), + Equals(" M file-one"), + Equals(" MM file-two"), + Equals(" ?? unstaged-file-two"), + ) + + t.FileSystem().FileContent("dir/file-one", Equals("original content\nnew 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/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,