/
githubmirror
/
lazygit
Обзор
Документация
Войти
/
githubmirror
/
lazygit
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
pkg/commands/git_commands/working_tree_test.go
911 строк
29 KB
Stefan Haller
Extract GitCommandBuilder method AddCommonDiffArgs
31 июл 2026, 09:42
31 июл 2026, 09:42
2694aa7
Код
Авторство
О чём код?
package git_commands import ( "testing" "github.com/go-errors/errors" "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" ) func TestWorkingTreeStageFile(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"add", "--", "test.txt"}, "", nil) instance := buildWorkingTreeCommands(commonDeps{runner: runner}) assert.NoError(t, instance.StageFile("test.txt")) runner.CheckForMissingCalls() } func TestWorkingTreeStageFiles(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"add", "--", "test.txt", "test2.txt"}, "", nil) instance := buildWorkingTreeCommands(commonDeps{runner: runner}) assert.NoError(t, instance.StageFiles([]string{"test.txt", "test2.txt"}, nil)) runner.CheckForMissingCalls() } func TestWorkingTreeUnstageFile(t *testing.T) { type scenario struct { testName string reset bool runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "Remove an untracked file from staging", reset: false, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rm", "--cached", "--force", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { testName: "Remove a tracked file from staging", reset: true, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "HEAD", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.UnStageFile([]string{"test.txt"}, s.reset)) }) } } // these tests don't cover everything, in part because we already have an integration // test which does cover everything. I don't want to unnecessarily assert on the 'how' // when the 'what' is what matters func TestWorkingTreeDiscardAllFileChanges(t *testing.T) { type scenario struct { testName string file *models.File removedFileErr error runner *oscommands.FakeCmdObjRunner expectedError string expectedRemovedFiles []string } scenarios := []scenario{ { testName: "An error occurred when resetting", file: &models.File{ Path: "test", HasStagedChanges: true, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", errors.New("error")), expectedError: "error", }, { testName: "An error occurred when removing file", file: &models.File{ Path: "test", Tracked: false, Added: true, }, 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", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: false, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", errors.New("error")), expectedError: "error", }, { testName: "Checkout only", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: false, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), }, { testName: "Reset and checkout staged changes", file: &models.File{ Path: "test", Tracked: true, HasStagedChanges: true, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), }, { testName: "Reset and checkout merge conflicts", file: &models.File{ Path: "test", Tracked: true, HasMergeConflicts: true, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil). ExpectGitArgs([]string{"checkout", "--", "test"}, "", nil), }, { testName: "Reset and remove", file: &models.File{ Path: "test", Tracked: false, Added: true, HasStagedChanges: true, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--", "test"}, "", nil), expectedRemovedFiles: []string{"test"}, }, { testName: "Remove only", file: &models.File{ Path: "test", Tracked: false, Added: true, HasStagedChanges: false, }, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"test"}, }, } 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 s.removedFileErr } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile}) err := instance.DiscardAllFileChanges(s.file) if s.expectedError == "" { assert.Nil(t, err) } else { assert.Equal(t, s.expectedError, err.Error()) } assert.Equal(t, s.expectedRemovedFiles, removedFiles) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiff(t *testing.T) { type scenario struct { testName string file *models.File plain bool cached bool ignoreWhitespace bool contextSize uint64 similarityThreshold int runner *oscommands.FakeCmdObjRunner } const expectedResult = "pretend this is an actual git diff" scenarios := []scenario{ { testName: "Default case", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "cached", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: true, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--cached", "--", "test.txt"}, expectedResult, nil), }, { testName: "plain", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: true, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=never", "--", "test.txt"}, expectedResult, nil), }, { testName: "File not tracked and file has no staged changes", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: false, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: true, contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 17, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=17", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom similarity threshold", file: &models.File{ Path: "test.txt", HasStagedChanges: false, Tracked: true, }, plain: false, cached: false, ignoreWhitespace: false, contextSize: 3, similarityThreshold: 33, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace userConfig.Git.DiffContextSize = s.contextSize userConfig.Git.RenameSimilarityThreshold = s.similarityThreshold repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) result := instance.WorktreeFileDiff(s.file, s.plain, s.cached) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeShowFileDiff(t *testing.T) { type scenario struct { testName string from string to string reverse bool fileName string previousPath string plain bool ignoreWhitespace bool contextSize uint64 runner *oscommands.FakeCmdObjRunner } const expectedResult = "pretend this is an actual git diff" scenarios := []scenario{ { testName: "Default case", from: "1234567890", to: "0987654321", reverse: false, fileName: "test.txt", plain: false, ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", from: "1234567890", to: "0987654321", reverse: false, fileName: "test.txt", plain: false, ignoreWhitespace: false, contextSize: 123, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=123", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", from: "1234567890", to: "0987654321", reverse: false, fileName: "test.txt", plain: false, ignoreWhitespace: true, contextSize: 3, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Renamed file passes both paths so the rename is detected", from: "1234567890", to: "0987654321", reverse: false, fileName: "new.txt", previousPath: "old.txt", plain: false, ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil), }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace userConfig.Git.DiffContextSize = s.contextSize repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", } instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain) assert.NoError(t, err) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeCheckoutFile(t *testing.T) { type scenario struct { testName string commitHash string fileName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "typical case", commitHash: "11af912", fileName: "test999.txt", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "11af912", "--", "test999.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, { testName: "returns error if there is one", commitHash: "11af912", fileName: "test999.txt", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "11af912", "--", "test999.txt"}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.CheckoutFile(s.commitHash, s.fileName)) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiscardUnstagedFileChanges(t *testing.T) { type scenario struct { testName string file *models.File runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", file: &models.File{Path: "test.txt"}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "test.txt"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.DiscardUnstagedFileChanges(s.file)) s.runner.CheckForMissingCalls() }) } } // testNode implements IFileNode for unit tests. type testNode struct { children []*testNode path string file *models.File // non-nil only for file nodes } func (n *testNode) ForEachFile(cb func(*models.File) error) error { if n.file != nil { return cb(n.file) } for _, child := range n.children { if err := child.ForEachFile(cb); err != nil { return err } } return nil } func (n *testNode) GetFilePathsMatching(test func(*models.File) bool) []string { if n.file != nil { if test(n.file) { return []string{n.path} } return nil } return lo.FlatMap(n.children, func(child *testNode, _ int) []string { return child.GetFilePathsMatching(test) }) } 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 dirsWithRemainingFiles []string // dirs where isDirEmpty returns false expectedRemovedFiles []string expectedRemovedDirs []string } scenarios := []scenario{ { testName: "multiple regular tracked files batched into a single checkout call", nodes: []IFileNode{&testNode{ children: []*testNode{ {path: "a.txt", file: &models.File{Path: "a.txt", Tracked: true}}, {path: "b.txt", file: &models.File{Path: "b.txt", Tracked: true}}, {path: "c.txt", file: &models.File{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{ children: []*testNode{ {path: "a.txt", file: &models.File{Path: "a.txt", Tracked: true, HasStagedChanges: true}}, {path: "b.txt", file: &models.File{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{ children: []*testNode{ {path: "new1.txt", file: &models.File{Path: "new1.txt", Added: true}}, {path: "new2.txt", file: &models.File{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", children: []*testNode{ {path: "dir1/a.txt", file: &models.File{Path: "dir1/a.txt", Tracked: true}}, {path: "dir1/b.txt", file: &models.File{Path: "dir1/b.txt", Added: true}}, }, }, &testNode{ path: "dir2", children: []*testNode{ {path: "dir2/c.txt", file: &models.File{Path: "dir2/c.txt", Tracked: true}}, {path: "dir2/d.txt", file: &models.File{Path: "dir2/d.txt", Added: true}}, }, }, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "dir1/a.txt", "dir2/c.txt"}, "", nil), dirsWithRemainingFiles: []string{"dir1", "dir2"}, // tracked files a.txt / c.txt remain expectedRemovedFiles: []string{"dir1/b.txt", "dir2/d.txt"}, }, { testName: "empty parent directory is removed after all its added files are deleted", nodes: []IFileNode{&testNode{ path: "dir", children: []*testNode{ { path: "dir/newdir", children: []*testNode{ {path: "dir/newdir/a.txt", file: &models.File{Path: "dir/newdir/a.txt", Added: true}}, {path: "dir/newdir/b.txt", file: &models.File{Path: "dir/newdir/b.txt", Added: true}}, }, }, }, }}, runner: oscommands.NewFakeRunner(t), dirsWithRemainingFiles: []string{"dir"}, // assume there are other tracked files in dir expectedRemovedFiles: []string{"dir/newdir/a.txt", "dir/newdir/b.txt"}, expectedRemovedDirs: []string{"dir/newdir"}, }, { testName: "nested empty directories are removed bottom-up", nodes: []IFileNode{&testNode{ path: "newdir", children: []*testNode{ { path: "newdir/sub", children: []*testNode{ {path: "newdir/sub/file.txt", file: &models.File{Path: "newdir/sub/file.txt", Added: true}}, }, }, }, }}, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"newdir/sub/file.txt"}, expectedRemovedDirs: []string{"newdir/sub", "newdir"}, }, { testName: "empty directory is NOT removed when individual file nodes are selected", nodes: []IFileNode{ &testNode{path: "newdir/a.txt", file: &models.File{Path: "newdir/a.txt", Added: true}}, &testNode{path: "newdir/b.txt", file: &models.File{Path: "newdir/b.txt", Added: true}}, }, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"newdir/a.txt", "newdir/b.txt"}, // newdir becomes empty but was not selected as a directory node, so it is not removed }, } 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 } isDirEmpty := func(path string) (bool, error) { return !lo.Contains(s.dirsWithRemainingFiles, path), nil } var removedDirs []string removeDir := func(path string) error { removedDirs = append(removedDirs, path) return nil } instance := buildWorkingTreeCommands(commonDeps{ runner: s.runner, removeFile: removeFile, isDirEmpty: isDirEmpty, removeDir: removeDir, }) err := instance.DiscardAllDirChanges(s.nodes) assert.NoError(t, err) assert.Equal(t, s.expectedRemovedFiles, removedFiles) assert.Equal(t, s.expectedRemovedDirs, removedDirs) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { type scenario struct { testName string nodes []IFileNode runner *oscommands.FakeCmdObjRunner dirsWithRemainingFiles []string // dirs where isDirEmpty returns false expectedRemovedFiles []string expectedRemovedDirs []string } scenarios := []scenario{ { testName: "directory node: removes untracked files and checks out tracked files by path, not by directory", nodes: []IFileNode{&testNode{ path: "dir", children: []*testNode{ {path: "dir/tracked1.txt", file: &models.File{Path: "dir/tracked1.txt", Tracked: true}}, {path: "dir/tracked2.txt", file: &models.File{Path: "dir/tracked2.txt", Tracked: true}}, {path: "dir/new.txt", file: &models.File{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), dirsWithRemainingFiles: []string{"dir"}, // tracked files remain in dir 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", children: []*testNode{ // 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", file: &models.File{Path: "dir/staged-new1.txt", Tracked: false, Added: true, HasStagedChanges: true}}, {path: "dir/staged-new2.txt", file: &models.File{Path: "dir/staged-new2.txt", Tracked: false, Added: true, HasStagedChanges: true}}, // Purely untracked file: removed from disk, not checked out. {path: "dir/untracked.txt", file: &models.File{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), dirsWithRemainingFiles: []string{"dir"}, // staged files remain in dir expectedRemovedFiles: []string{"dir/untracked.txt"}, }, { testName: "file node: added and unstaged file is removed from disk", nodes: []IFileNode{&testNode{ path: "new.txt", 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", children: []*testNode{ {path: "dir1/tracked.txt", file: &models.File{Path: "dir1/tracked.txt", Tracked: true}}, {path: "dir1/untracked.txt", file: &models.File{Path: "dir1/untracked.txt", Tracked: false}}, }, }, &testNode{ path: "dir2", children: []*testNode{ {path: "dir2/tracked.txt", file: &models.File{Path: "dir2/tracked.txt", Tracked: true}}, {path: "dir2/untracked.txt", file: &models.File{Path: "dir2/untracked.txt", Tracked: false}}, }, }, }, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "dir1/tracked.txt", "dir2/tracked.txt"}, "", nil), dirsWithRemainingFiles: []string{"dir1", "dir2"}, // tracked files remain expectedRemovedFiles: []string{"dir1/untracked.txt", "dir2/untracked.txt"}, }, { testName: "empty untracked directory is removed after its files are deleted", nodes: []IFileNode{&testNode{ path: "newdir", children: []*testNode{ {path: "newdir/a.txt", file: &models.File{Path: "newdir/a.txt", Tracked: false}}, {path: "newdir/b.txt", file: &models.File{Path: "newdir/b.txt", Tracked: false}}, }, }}, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"newdir/a.txt", "newdir/b.txt"}, expectedRemovedDirs: []string{"newdir"}, }, { testName: "empty directory is NOT removed when individual file nodes are selected", nodes: []IFileNode{ &testNode{path: "newdir/a.txt", file: &models.File{Path: "newdir/a.txt", Tracked: false}}, &testNode{path: "newdir/b.txt", file: &models.File{Path: "newdir/b.txt", Tracked: false}}, }, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"newdir/a.txt", "newdir/b.txt"}, // newdir becomes empty but was not selected as a directory node, so it is not removed }, } 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 } isDirEmpty := func(path string) (bool, error) { return !lo.Contains(s.dirsWithRemainingFiles, path), nil } var removedDirs []string removeDir := func(path string) error { removedDirs = append(removedDirs, path) return nil } instance := buildWorkingTreeCommands(commonDeps{ runner: s.runner, removeFile: removeFile, isDirEmpty: isDirEmpty, removeDir: removeDir, }) assert.NoError(t, instance.DiscardUnstagedDirChanges(s.nodes)) s.runner.CheckForMissingCalls() assert.Equal(t, s.expectedRemovedFiles, removedFiles) assert.Equal(t, s.expectedRemovedDirs, removedDirs) }) } } func TestWorkingTreeDiscardAnyUnstagedFileChanges(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"checkout", "--", "."}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.DiscardAnyUnstagedFileChanges()) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeRemoveUntrackedFiles(t *testing.T) { type scenario struct { testName string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { testName: "valid case", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"clean", "-fd"}, "", nil), test: func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.RemoveUntrackedFiles()) s.runner.CheckForMissingCalls() }) } } func TestWorkingTreeResetHard(t *testing.T) { type scenario struct { testName string ref string runner *oscommands.FakeCmdObjRunner test func(error) } scenarios := []scenario{ { "valid case", "HEAD", oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"reset", "--hard", "HEAD"}, "", nil), func(err error) { assert.NoError(t, err) }, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) s.test(instance.ResetHard(s.ref)) }) } } func TestWorkingTreeCommands_AllRepoFiles(t *testing.T) { scenarios := []struct { name string runner *oscommands.FakeCmdObjRunner expected []string }{ { name: "no files", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"ls-files", "-z"}, "", nil), expected: []string{}, }, { name: "two files", runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"ls-files", "-z"}, "dir/file1.txt\x00dir2/file2.go\x00", nil), expected: []string{"dir/file1.txt", "dir2/file2.go"}, }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner}) result, err := instance.AllRepoFiles() assert.NoError(t, err) assert.Equal(t, s.expected, result) }) } }