mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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>
912 lines
29 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|