From 46d8437bc2f568eda6389c8e917b595de1212d56 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 23 Mar 2026 13:21:13 +0100 Subject: [PATCH 1/3] Change test setup to allow nested directories --- .../git_commands/working_tree_test.go | 88 ++++++++++--------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index ce9d7971d..0b07a2c76 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -475,14 +475,17 @@ func TestWorkingTreeDiscardUnstagedFileChanges(t *testing.T) { // testNode implements IFileNode for unit tests. type testNode struct { - files []*models.File // all leaf files under this node - path string - file *models.File // non-nil only for file nodes + children []*testNode + path string + file *models.File // non-nil only for file nodes } func (n *testNode) ForEachFile(cb func(*models.File) error) error { - for _, f := range n.files { - if err := cb(f); err != nil { + if n.file != nil { + return cb(n.file) + } + for _, child := range n.children { + if err := child.ForEachFile(cb); err != nil { return err } } @@ -490,8 +493,14 @@ func (n *testNode) ForEachFile(cb func(*models.File) error) error { } func (n *testNode) GetFilePathsMatching(test func(*models.File) bool) []string { - return lo.FilterMap(n.files, func(f *models.File, _ int) (string, bool) { - return f.Path, test(f) + 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) }) } @@ -510,10 +519,10 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { { testName: "multiple regular tracked files batched into a single checkout call", nodes: []IFileNode{&testNode{ - files: []*models.File{ - {Path: "a.txt", Tracked: true}, - {Path: "b.txt", Tracked: true}, - {Path: "c.txt", Tracked: true}, + 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). @@ -522,9 +531,9 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { { testName: "staged files batched into a single reset then a single checkout", nodes: []IFileNode{&testNode{ - files: []*models.File{ - {Path: "a.txt", Tracked: true, HasStagedChanges: true}, - {Path: "b.txt", Tracked: true, HasStagedChanges: true}, + 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). @@ -534,9 +543,9 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { { testName: "added files with no staged changes are removed from disk without any git call", nodes: []IFileNode{&testNode{ - files: []*models.File{ - {Path: "new1.txt", Added: true}, - {Path: "new2.txt", Added: true}, + 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), @@ -547,16 +556,16 @@ func TestWorkingTreeDiscardAllDirChanges(t *testing.T) { nodes: []IFileNode{ &testNode{ path: "dir1", - files: []*models.File{ - {Path: "dir1/a.txt", Tracked: true}, - {Path: "dir1/b.txt", Added: true}, + 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", - files: []*models.File{ - {Path: "dir2/c.txt", Tracked: true}, - {Path: "dir2/d.txt", Added: true}, + 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}}, }, }, }, @@ -595,10 +604,10 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { testName: "directory node: removes untracked files and checks out tracked files by path, not by directory", nodes: []IFileNode{&testNode{ path: "dir", - files: []*models.File{ - {Path: "dir/tracked1.txt", Tracked: true}, - {Path: "dir/tracked2.txt", Tracked: true}, - {Path: "dir/new.txt", Tracked: false}, + 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. @@ -610,13 +619,13 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { testName: "directory node: staged-but-not-committed file (Tracked=false, HasStagedChanges=true) is left alone; purely untracked file is removed", nodes: []IFileNode{&testNode{ path: "dir", - files: []*models.File{ + 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", Tracked: false, Added: true, HasStagedChanges: true}, - {Path: "dir/staged-new2.txt", Tracked: false, Added: true, HasStagedChanges: true}, + {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", Tracked: false, Added: true, HasStagedChanges: false}, + {path: "dir/untracked.txt", file: &models.File{Path: "dir/untracked.txt", Tracked: false, Added: true, HasStagedChanges: false}}, }, }}, runner: oscommands.NewFakeRunner(t). @@ -626,9 +635,8 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { { testName: "file node: added and unstaged file is removed from disk", nodes: []IFileNode{&testNode{ - path: "new.txt", - files: []*models.File{{Path: "new.txt", Added: true}}, - file: &models.File{Path: "new.txt", Added: true, HasStagedChanges: false}, + path: "new.txt", + file: &models.File{Path: "new.txt", Added: true, HasStagedChanges: false}, }}, runner: oscommands.NewFakeRunner(t), expectedRemovedFiles: []string{"new.txt"}, @@ -638,16 +646,16 @@ func TestWorkingTreeDiscardUnstagedDirChanges(t *testing.T) { nodes: []IFileNode{ &testNode{ path: "dir1", - files: []*models.File{ - {Path: "dir1/tracked.txt", Tracked: true}, - {Path: "dir1/untracked.txt", Tracked: false}, + 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", - files: []*models.File{ - {Path: "dir2/tracked.txt", Tracked: true}, - {Path: "dir2/untracked.txt", Tracked: false}, + 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}}, }, }, }, From b4dae453af798ce4567f505864028b8d5682ab3d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 22:29:09 +0100 Subject: [PATCH 2/3] Extract helper method removeFiles --- pkg/commands/git_commands/working_tree.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index c01a582ff..e9ebbd7a4 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -238,10 +238,8 @@ func (self *WorkingTreeCommands) DiscardAllDirChanges(nodes []IFileNode) error { return err } - for _, path := range filesToRemove { - if err := self.os.RemoveFile(path); err != nil { - return err - } + if err := self.removeFiles(filesToRemove); err != nil { + return err } return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) @@ -268,13 +266,22 @@ func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(nodes []IFileNode) er }) } - for _, path := range filesToRemove { + if err := self.removeFiles(filesToRemove); err != nil { + return err + } + + return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) +} + +// Removes the given files from disk. +func (self *WorkingTreeCommands) removeFiles(paths []string) error { + for _, path := range paths { if err := self.os.RemoveFile(path); err != nil { return err } } - return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd) + return nil } func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error { From 2a6648f0f66e747747d131c77ffd22502a011f86 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 22 Mar 2026 21:38:25 +0100 Subject: [PATCH 3/3] Remove empty directories after discarding files When discarding untracked files, remove any directories that have become empty because of this. --- pkg/commands/git_commands/deps_test.go | 14 ++ pkg/commands/git_commands/working_tree.go | 66 ++++++++- .../git_commands/working_tree_test.go | 129 ++++++++++++++++-- pkg/commands/oscommands/dummies.go | 4 + pkg/commands/oscommands/os.go | 33 +++++ pkg/i18n/english.go | 2 + 6 files changed, 230 insertions(+), 18 deletions(-) diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 87deefde7..85de496dc 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -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(), }) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index e9ebbd7a4..328da1404 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -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() }, diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 0b07a2c76..bb4c8e750 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -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) }) } } diff --git a/pkg/commands/oscommands/dummies.go b/pkg/commands/oscommands/dummies.go index 9490a970d..27b0b372d 100644 --- a/pkg/commands/oscommands/dummies.go +++ b/pkg/commands/oscommands/dummies.go @@ -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, } diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go index f070e4856..f1ee86c4c 100644 --- a/pkg/commands/oscommands/os.go +++ b/pkg/commands/oscommands/os.go @@ -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) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 4c423c2bc..63ed43c5f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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}}'",