jesseduffield.lazygit/pkg/commands/git_commands/working_tree_test.go
Stefan Haller f84ada4941 Show renamed files in the custom patch builder
When loading the files of a commit we passed --no-renames, so a rename
showed up as a separate delete and add rather than a single R entry.
That made it impossible to work with a rename that also modifies the
file: the modifications were spread across a full deletion and a full
addition instead of appearing as the handful of lines that actually
changed. The staging view already shows renames and lets you stage
their hunks, so there was no good reason for the patch builder to
differ; the flag was only there because the commit-file parser couldn't
cope with the rename record format.

Switch the commit-file loader and the per-file diff to --find-renames,
teach the parser about the rename record (a status followed by two
paths), and carry the previous path through the patch builder so the
diff for a rename is loaded with both paths, which is what makes git
emit the rename in the first place.

A whole-file selection keeps the rename in the header, so the rename
moves or is discarded together with the file's contents. A partial
selection instead strips the rename metadata and points the header at
the new path, so applying the patch only changes the contents and
leaves the rename in place; the blob index line is kept so that a 3-way
apply can still fall back to a blob merge.

Discarding a renamed file from a commit now discards both the new and
the old path, so the new file is removed and the old one is restored.

Changing the rename similarity threshold refreshes the commit files
panel too, not just the files panel, so that a rename can turn into a
delete and add or back. It is disabled while building a patch, however,
because the patch builder caches each file's diff by path and would
desync if a rename changed into a delete and add underneath it.

Finally, copying a file's diff from the commit files panel now passes
both paths for a rename, so the copied diff shows the rename instead of
a new-file add.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:05:09 +02:00

912 lines
29 KiB
Go

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", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--", "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", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--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", "--submodule", "--unified=3", "--color=never", "--find-renames=50%", "--", "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", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--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", "--submodule", "--unified=3", "--color=always", "--ignore-all-space", "--find-renames=50%", "--", "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", "--submodule", "--unified=17", "--color=always", "--find-renames=50%", "--", "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", "--submodule", "--unified=3", "--color=always", "--find-renames=33%", "--", "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", "--submodule", "--unified=3", "--find-renames=50%", "--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", "--submodule", "--unified=123", "--find-renames=50%", "--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", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "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", "--submodule", "--unified=3", "--find-renames=50%", "--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)
})
}
}