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:
Stefan Haller 2026-03-22 21:38:25 +01:00
parent b4dae453af
commit 2a6648f0f6
6 changed files with 230 additions and 18 deletions

View file

@ -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(),
})

View file

@ -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() },

View file

@ -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)
})
}
}

View file

@ -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,
}

View file

@ -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)
}

View file

@ -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}}'",