Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cmd/wtp/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ func removeCommandWithCommandExecutor(
return err
}

// Clean up empty parent directories between the worktree and base_dir.
// Deferred so it runs after any subsequent git operations (e.g. branch
// deletion) which might need a valid working directory.
defer cleanupEmptyParentDirs(absTargetPath, worktrees)

// Remove branch if requested
if withBranch && targetWorktree.Branch != "" {
if err := removeBranchWithCommandExecutor(w, executor, targetWorktree.Branch, forceBranch); err != nil {
Expand Down Expand Up @@ -275,6 +280,67 @@ func findTargetWorktreeFromList(worktrees []git.Worktree, worktreeName string) (
return targetWorktree, nil
}

// cleanupEmptyParentDirs removes empty directories between the removed worktree
// and the resolved base_dir. This keeps the worktree base directory clean when
// branches use prefixed names like "feat/my-feature" or "fix/bug-123".
// Cleanup is best-effort; errors are silently ignored.
func cleanupEmptyParentDirs(worktreePath string, worktrees []git.Worktree) {
var mainWorktreePath string
for _, wt := range worktrees {
if wt.IsMain {
mainWorktreePath = wt.Path
break
}
}
if mainWorktreePath == "" {
return
}

cfg, err := config.LoadConfig(mainWorktreePath)
if err != nil {
return
}

baseDir := cfg.Defaults.BaseDir
if !filepath.IsAbs(baseDir) {
baseDir = filepath.Join(mainWorktreePath, baseDir)
}
absBaseDir, err := filepath.Abs(baseDir)
if err != nil {
return
}

removeEmptyParentsUpTo(worktreePath, absBaseDir)
}

// removeEmptyParentsUpTo walks from the parent of dir up to (but not including)
// stopAt, removing each directory only if it is empty. It stops at the first
// non-empty directory or when it reaches stopAt.
func removeEmptyParentsUpTo(dir, stopAt string) {
current := filepath.Dir(dir)
for {
absCurrent, err := filepath.Abs(current)
if err != nil {
return
}

if absCurrent == stopAt || !isPathWithin(stopAt, absCurrent) {
return
}

entries, err := os.ReadDir(absCurrent)
if err != nil || len(entries) > 0 {
return
}

if err := os.Remove(absCurrent); err != nil {
return
}

current = filepath.Dir(absCurrent)
}
}

// getWorktreeNameFromPath calculates the worktree name from its path
// For main worktree, returns "@"
// For other worktrees, returns relative path from base_dir
Expand Down
125 changes: 125 additions & 0 deletions cmd/wtp/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/urfave/cli/v3"

"github.com/satococoa/wtp/v2/internal/command"
"github.com/satococoa/wtp/v2/internal/git"
)

// ===== Command Structure Tests =====
Expand Down Expand Up @@ -848,6 +849,130 @@ func (e *mockRemoveError) Error() string {
return e.message
}

// ===== Empty Parent Directory Cleanup Tests =====

func TestRemoveEmptyParentsUpTo(t *testing.T) {
t.Run("removes single empty parent", func(t *testing.T) {
// base/feat/my-feature -> after removing my-feature, feat/ should be cleaned
base := t.TempDir()
feat := filepath.Join(base, "feat")
worktree := filepath.Join(feat, "my-feature")
assert.NoError(t, os.MkdirAll(worktree, 0o755))

// Simulate git having already removed the worktree directory
assert.NoError(t, os.Remove(worktree))

removeEmptyParentsUpTo(worktree, base)

_, err := os.Stat(feat)
assert.True(t, os.IsNotExist(err), "empty feat/ dir should be removed")
})

t.Run("removes multiple nested empty parents", func(t *testing.T) {
// base/a/b/c -> after removing c, both b/ and a/ should be cleaned
base := t.TempDir()
deepDir := filepath.Join(base, "a", "b", "c")
assert.NoError(t, os.MkdirAll(deepDir, 0o755))

assert.NoError(t, os.Remove(deepDir))

removeEmptyParentsUpTo(deepDir, base)

_, err := os.Stat(filepath.Join(base, "a"))
assert.True(t, os.IsNotExist(err), "empty a/ dir should be removed")
})

t.Run("stops at non-empty directory", func(t *testing.T) {
// base/feat/branch-a and base/feat/branch-b
// removing branch-a should leave feat/ because branch-b exists
base := t.TempDir()
feat := filepath.Join(base, "feat")
branchA := filepath.Join(feat, "branch-a")
branchB := filepath.Join(feat, "branch-b")
assert.NoError(t, os.MkdirAll(branchA, 0o755))
assert.NoError(t, os.MkdirAll(branchB, 0o755))

assert.NoError(t, os.Remove(branchA))

removeEmptyParentsUpTo(branchA, base)

_, err := os.Stat(feat)
assert.NoError(t, err, "feat/ should still exist because branch-b is in it")
})

t.Run("does not remove base_dir itself", func(t *testing.T) {
base := t.TempDir()
worktree := filepath.Join(base, "my-feature")
assert.NoError(t, os.MkdirAll(worktree, 0o755))

assert.NoError(t, os.Remove(worktree))

removeEmptyParentsUpTo(worktree, base)

_, err := os.Stat(base)
assert.NoError(t, err, "base_dir itself should not be removed")
})

t.Run("handles already-cleaned directories gracefully", func(t *testing.T) {
base := t.TempDir()
worktree := filepath.Join(base, "feat", "gone")

// Nothing exists on disk; the walk should bail out on ReadDir and
// leave base/ untouched.
removeEmptyParentsUpTo(worktree, base)

_, err := os.Stat(base)
assert.NoError(t, err, "base should still exist")
})
}

func TestCleanupEmptyParentDirs(t *testing.T) {
t.Run("cleans prefix dir using configured base_dir", func(t *testing.T) {
// Lay out: mainRepo/ (with .wtp.yml) and mainRepo/../wts/feat/my-feature.
tmp := t.TempDir()
mainRepo := filepath.Join(tmp, "repo")
baseDir := filepath.Join(tmp, "wts")
assert.NoError(t, os.MkdirAll(mainRepo, 0o755))
assert.NoError(t, os.WriteFile(
filepath.Join(mainRepo, ".wtp.yml"),
[]byte("version: \"1.0\"\ndefaults:\n base_dir: ../wts\n"),
0o600,
))

featDir := filepath.Join(baseDir, "feat")
worktreePath := filepath.Join(featDir, "my-feature")
assert.NoError(t, os.MkdirAll(worktreePath, 0o755))

// Simulate git having already removed the worktree directory itself.
assert.NoError(t, os.Remove(worktreePath))

worktrees := []git.Worktree{
{Path: mainRepo, IsMain: true},
{Path: worktreePath},
}
cleanupEmptyParentDirs(worktreePath, worktrees)

_, err := os.Stat(featDir)
assert.True(t, os.IsNotExist(err), "feat/ prefix dir should be removed")
_, err = os.Stat(baseDir)
assert.NoError(t, err, "base_dir itself should remain")
})

t.Run("no-op when main worktree is missing from list", func(t *testing.T) {
// Without a main worktree we can't resolve base_dir, so nothing
// should be removed even if the parent is empty.
tmp := t.TempDir()
featDir := filepath.Join(tmp, "feat")
worktreePath := filepath.Join(featDir, "my-feature")
assert.NoError(t, os.MkdirAll(featDir, 0o755))

cleanupEmptyParentDirs(worktreePath, []git.Worktree{{Path: "/some/other/wt"}})

_, err := os.Stat(featDir)
assert.NoError(t, err, "feat/ should be untouched when main is unknown")
})
}

// ===== Worktree Completion Tests =====

func TestGetWorktreeNameFromPath(t *testing.T) {
Expand Down
Loading