Improve performance of discarding many files (#5407)

When selecting a directory containing many changed files, or when
range-selecting many changed files, and then discarding them, this could
be so slow that lazygit appeared to hang; especially since there was no
UI feedback (e.g. a spinning status message).

This PR improves the performance greatly by batching the individual git
calls into a single one if possible. It still doesn't add UI feedback
though, hoping that the operation is now fast enough that it isn't
needed.

While we're at it, we fix a bug that would cause "Discard unstaged
changes" to discard more than just the visible files when filtering by
path.

Fixes #4581.
This commit is contained in:
Stefan Haller 2026-03-27 14:40:30 +01:00 committed by GitHub
commit 557ba51148
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 527 additions and 67 deletions

View file

@ -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 <subcommand> -- <paths...>`, 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
}

View file

@ -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()
})
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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}})

View file

@ -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)
})
}

View file

@ -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"))
},
})

View file

@ -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"))
},
})

View file

@ -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,