mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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:
commit
557ba51148
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
},
|
||||
})
|
||||
|
|
@ -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"))
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue