mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 09:06:27 -04:00
Remove empty directories after discarding files
When discarding untracked files, remove any directories that have become empty because of this.
This commit is contained in:
parent
b4dae453af
commit
2a6648f0f6
|
|
@ -19,6 +19,8 @@ type commonDeps struct {
|
|||
gitConfig *git_config.FakeGitConfig
|
||||
getenv func(string) string
|
||||
removeFile func(string) error
|
||||
isDirEmpty func(string) (bool, error)
|
||||
removeDir func(string) error
|
||||
common *common.Common
|
||||
cmd *oscommands.CmdObjBuilder
|
||||
fs afero.Fs
|
||||
|
|
@ -86,11 +88,23 @@ func buildGitCommon(deps commonDeps) *GitCommon {
|
|||
removeFile = func(string) error { return errors.New("unexpected call to removeFile") }
|
||||
}
|
||||
|
||||
isDirEmpty := deps.isDirEmpty
|
||||
if isDirEmpty == nil {
|
||||
isDirEmpty = func(string) (bool, error) { return false, nil }
|
||||
}
|
||||
|
||||
removeDir := deps.removeDir
|
||||
if removeDir == nil {
|
||||
removeDir = func(string) error { return errors.New("unexpected call to removeDir") }
|
||||
}
|
||||
|
||||
gitCommon.os = oscommands.NewDummyOSCommandWithDeps(oscommands.OSCommandDeps{
|
||||
Common: gitCommon.Common,
|
||||
GetenvFn: getenv,
|
||||
Cmd: cmd,
|
||||
RemoveFileFn: removeFile,
|
||||
IsDirEmptyFn: isDirEmpty,
|
||||
RemoveDirFn: removeDir,
|
||||
TempDir: os.TempDir(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ package git_commands
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type WorkingTreeCommands struct {
|
||||
|
|
@ -238,7 +241,7 @@ func (self *WorkingTreeCommands) DiscardAllDirChanges(nodes []IFileNode) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := self.removeFiles(filesToRemove); err != nil {
|
||||
if err := self.removeFiles(filesToRemove, nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -266,24 +269,79 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(nodes []IFileNode) er
|
|||
})
|
||||
}
|
||||
|
||||
if err := self.removeFiles(filesToRemove); err != nil {
|
||||
if err := self.removeFiles(filesToRemove, nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd)
|
||||
}
|
||||
|
||||
// Removes the given files from disk.
|
||||
func (self *WorkingTreeCommands) removeFiles(paths []string) error {
|
||||
// Removes the given files from disk, and also removes any directories that have become empty
|
||||
// because of this.
|
||||
func (self *WorkingTreeCommands) removeFiles(paths []string, selectedNodes []IFileNode) error {
|
||||
for _, path := range paths {
|
||||
if err := self.os.RemoveFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return self.removeEmptyDirs(paths, selectedDirPaths(selectedNodes))
|
||||
}
|
||||
|
||||
// Removes empty directories left behind after deleting files, but only for directories that
|
||||
// are at or below a selected directory node. It works bottom-up so that nested empty directories
|
||||
// are also cleaned up. Directories that still have contents are skipped.
|
||||
func (self *WorkingTreeCommands) removeEmptyDirs(removedFilePaths []string, selectedDirs []string) error {
|
||||
candidates := set.NewFromSlice(
|
||||
lo.FilterMap(removedFilePaths, func(filePath string, _ int) (string, bool) {
|
||||
dir := path.Dir(filePath)
|
||||
return dir, dir != "." && isUnderSelectedDir(dir, selectedDirs)
|
||||
}))
|
||||
|
||||
for {
|
||||
var removed []string
|
||||
for _, dir := range candidates.ToSlice() {
|
||||
empty, err := self.os.IsDirEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
if err := self.os.RemoveDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
removed = append(removed, dir)
|
||||
}
|
||||
}
|
||||
if len(removed) == 0 {
|
||||
break
|
||||
}
|
||||
for _, dir := range removed {
|
||||
candidates.Remove(dir)
|
||||
if parent := path.Dir(dir); parent != "." && isUnderSelectedDir(parent, selectedDirs) {
|
||||
candidates.Add(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnderSelectedDir(path string, selectedDirs []string) bool {
|
||||
isSubdir := func(parent, child string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
return err == nil && !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
|
||||
return lo.SomeBy(selectedDirs, func(selectedDir string) bool {
|
||||
return isSubdir(selectedDir, path)
|
||||
})
|
||||
}
|
||||
|
||||
func selectedDirPaths(nodes []IFileNode) []string {
|
||||
return lo.FilterMap(nodes, func(node IFileNode, _ int) (string, bool) {
|
||||
return node.GetPath(), node.GetFile() == nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error {
|
||||
untrackedFilePaths := node.GetFilePathsMatching(
|
||||
func(file *models.File) bool { return !file.GetIsTracked() && !file.GetHasStagedChanges() },
|
||||
|
|
|
|||
|
|
@ -509,10 +509,12 @@ 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
|
||||
testName string
|
||||
nodes []IFileNode
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
dirsWithRemainingFiles []string // dirs where isDirEmpty returns false
|
||||
expectedRemovedFiles []string
|
||||
expectedRemovedDirs []string
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
|
|
@ -571,7 +573,54 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) {
|
|||
},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"checkout", "--", "dir1/a.txt", "dir2/c.txt"}, "", nil),
|
||||
expectedRemovedFiles: []string{"dir1/b.txt", "dir2/d.txt"},
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -582,10 +631,22 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) {
|
|||
removedFiles = append(removedFiles, path)
|
||||
return nil
|
||||
}
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile})
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
|
@ -593,10 +654,12 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) {
|
|||
|
||||
func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
nodes []IFileNode
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
expectedRemovedFiles []string
|
||||
testName string
|
||||
nodes []IFileNode
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
dirsWithRemainingFiles []string // dirs where isDirEmpty returns false
|
||||
expectedRemovedFiles []string
|
||||
expectedRemovedDirs []string
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
|
|
@ -613,7 +676,8 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) {
|
|||
// 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"},
|
||||
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",
|
||||
|
|
@ -630,7 +694,8 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) {
|
|||
}},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"checkout", "--", "dir/staged-new1.txt", "dir/staged-new2.txt"}, "", nil),
|
||||
expectedRemovedFiles: []string{"dir/untracked.txt"},
|
||||
dirsWithRemainingFiles: []string{"dir"}, // staged files remain in dir
|
||||
expectedRemovedFiles: []string{"dir/untracked.txt"},
|
||||
},
|
||||
{
|
||||
testName: "file node: added and unstaged file is removed from disk",
|
||||
|
|
@ -661,7 +726,31 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) {
|
|||
},
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"checkout", "--", "dir1/tracked.txt", "dir2/tracked.txt"}, "", nil),
|
||||
expectedRemovedFiles: []string{"dir1/untracked.txt", "dir2/untracked.txt"},
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -672,10 +761,22 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) {
|
|||
removedFiles = append(removedFiles, path)
|
||||
return nil
|
||||
}
|
||||
instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, removeFile: removeFile})
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ type OSCommandDeps struct {
|
|||
Platform *Platform
|
||||
GetenvFn func(string) string
|
||||
RemoveFileFn func(string) error
|
||||
IsDirEmptyFn func(string) (bool, error)
|
||||
RemoveDirFn func(string) error
|
||||
Cmd *CmdObjBuilder
|
||||
TempDir string
|
||||
}
|
||||
|
|
@ -38,6 +40,8 @@ func NewDummyOSCommandWithDeps(deps OSCommandDeps) *OSCommand {
|
|||
Platform: platform,
|
||||
getenvFn: deps.GetenvFn,
|
||||
removeFileFn: deps.RemoveFileFn,
|
||||
isDirEmptyFn: deps.IsDirEmptyFn,
|
||||
removeDirFn: deps.RemoveDirFn,
|
||||
guiIO: NewNullGuiIO(utils.NewDummyLog()),
|
||||
tempDir: deps.TempDir,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type OSCommand struct {
|
|||
guiIO *guiIO
|
||||
|
||||
removeFileFn func(string) error
|
||||
isDirEmptyFn func(string) (bool, error)
|
||||
removeDirFn func(string) error
|
||||
|
||||
Cmd *CmdObjBuilder
|
||||
|
||||
|
|
@ -48,6 +50,8 @@ func NewOSCommand(common *common.Common, config config.AppConfigurer, platform *
|
|||
Platform: platform,
|
||||
getenvFn: os.Getenv,
|
||||
removeFileFn: os.RemoveAll,
|
||||
isDirEmptyFn: isDirEmpty,
|
||||
removeDirFn: os.Remove,
|
||||
guiIO: guiIO,
|
||||
tempDir: config.GetTempDir(),
|
||||
}
|
||||
|
|
@ -312,6 +316,35 @@ func (c *OSCommand) RemoveFile(path string) error {
|
|||
return c.removeFileFn(path)
|
||||
}
|
||||
|
||||
func (c *OSCommand) IsDirEmpty(path string) (bool, error) {
|
||||
return c.isDirEmptyFn(path)
|
||||
}
|
||||
|
||||
func (c *OSCommand) RemoveDir(path string) error {
|
||||
msg := utils.ResolvePlaceholderString(
|
||||
c.Tr.Log.RemoveEmptyDir,
|
||||
map[string]string{
|
||||
"path": path,
|
||||
},
|
||||
)
|
||||
c.LogCommand(msg, false)
|
||||
|
||||
return c.removeDirFn(path)
|
||||
}
|
||||
|
||||
func isDirEmpty(path string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = f.Readdirnames(1)
|
||||
_ = f.Close()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (c *OSCommand) Getenv(key string) string {
|
||||
return c.getenvFn(key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -953,6 +953,7 @@ type Log struct {
|
|||
EditRebase string
|
||||
HandleUndo string
|
||||
RemoveFile string
|
||||
RemoveEmptyDir string
|
||||
CopyToClipboard string
|
||||
Remove string
|
||||
CreateFileWithContent string
|
||||
|
|
@ -2182,6 +2183,7 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
EditRebase: "Beginning interactive rebase at '{{.ref}}'",
|
||||
HandleUndo: "Undoing last conflict resolution",
|
||||
RemoveFile: "Deleting path '{{.path}}'",
|
||||
RemoveEmptyDir: "Deleting empty directory '{{.path}}'",
|
||||
CopyToClipboard: "Copying '{{.str}}' to clipboard",
|
||||
Remove: "Removing '{{.filename}}'",
|
||||
CreateFileWithContent: "Creating file '{{.path}}'",
|
||||
|
|
|
|||
Loading…
Reference in a new issue