Skip to content
Merged
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
73 changes: 60 additions & 13 deletions cmd/wt/prune_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ Use --global to prune all registered repos.
Use --interactive to select worktrees to prune.

Target specific worktrees using [scope:]branch arguments where scope can be
a repo name or label. Use -f when targeting specific worktrees.`,
a repo name or label. Merged worktrees (locally merged or merged PR) can be
pruned without -f. Use -f to prune unmerged worktrees.`,
Example: ` wt prune # Remove worktrees with merged PRs
wt prune --stale # Also prune stale worktrees
wt prune --global # Prune all repos
wt prune -d # Dry-run: preview without removing
wt prune -i # Interactive mode
wt prune feature -f # Remove feature worktree (current repo)
wt prune feature # Remove merged feature worktree
wt prune feature -f # Remove unmerged feature worktree
wt prune feature -f -g # Remove feature worktree (all repos)
wt prune myrepo:feature -f # Remove specific worktree
wt prune myrepo:feature -f # Remove specific unmerged worktree
wt prune backend:main -f # Remove main in backend-labeled repos`,
ValidArgsFunction: completeScopedWorktreeArg,
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -76,9 +78,6 @@ a repo name or label. Use -f when targeting specific worktrees.`,

// If specific targets provided, handle targeted removal
if len(args) > 0 {
if !force {
return fmt.Errorf("targeting specific worktrees requires -f/--force")
}
deleteBranchesExplicit := cmd.Flags().Changed("delete-branches") || cmd.Flags().Changed("no-delete-branches")
// Determine if we should delete local branches
shouldDeleteBranches := cfg.Prune.DeleteLocalBranches
Expand All @@ -87,7 +86,7 @@ a repo name or label. Use -f when targeting specific worktrees.`,
} else if cmd.Flags().Changed("no-delete-branches") {
shouldDeleteBranches = false
}
return runPruneTargets(ctx, reg, args, global, dryRun, pruneOpts{
return runPruneTargets(ctx, reg, args, global, force, dryRun, pruneOpts{
DeleteBranches: shouldDeleteBranches,
DeleteBranchesExplicit: deleteBranchesExplicit,
Hooks: hf,
Expand Down Expand Up @@ -277,7 +276,7 @@ a repo name or label. Use -f when targeting specific worktrees.`,
}

cmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "Preview without removing")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force remove (required for targeted prune)")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Force remove unmerged worktrees")
cmd.Flags().BoolVarP(&global, "global", "g", false, "Prune all repos")
cmd.Flags().BoolVarP(&refresh, "refresh-pr", "R", false, "Refresh PR status first")
cmd.Flags().BoolVar(&resetCache, "reset-cache", false, "Clear all cached data")
Expand Down Expand Up @@ -312,7 +311,8 @@ func isStaleWorktree(wt git.Worktree, staleDays int) bool {

// runPruneTargets handles removal of specific worktrees by [scope:]branch args.
// When global is false, unscoped targets are scoped to the current repo.
func runPruneTargets(ctx context.Context, reg *registry.Registry, targets []string, global, dryRun bool, opts pruneOpts) error {
// Force is only required when at least one target is not prunable (not merged).
func runPruneTargets(ctx context.Context, reg *registry.Registry, targets []string, global, force, dryRun bool, opts pruneOpts) error {
l := log.FromContext(ctx)
out := output.FromContext(ctx)

Expand Down Expand Up @@ -349,16 +349,54 @@ func runPruneTargets(ctx context.Context, reg *registry.Registry, targets []stri
})
}

// Enrich with merge info to determine if force is needed
prCache := prcache.Load()

// Check locally-merged status per unique repo
repoIndices := make(map[string][]int)
for i, wt := range toRemove {
repoIndices[wt.RepoPath] = append(repoIndices[wt.RepoPath], i)
}
for repoPath, indices := range repoIndices {
defaultBranch := git.GetDefaultBranch(ctx, repoPath)
mergedBranches, err := git.GetMergedBranches(ctx, repoPath, defaultBranch)
if err != nil {
l.Printf("Warning: could not check merged branches for %s: %v (will require --force)\n", filepath.Base(repoPath), err)
continue
}
for _, i := range indices {
toRemove[i].LocallyMerged = mergedBranches[toRemove[i].Branch]
}
}

// Enrich with PR state from cache
populatePRFields(toRemove, prCache)

// Require force only when at least one target is not prunable
if !force {
var unprunable []string
for _, wt := range toRemove {
if !isWorktreePrunable(wt) {
unprunable = append(unprunable, wt.RepoName+":"+wt.Branch)
}
}
if len(unprunable) > 0 {
return fmt.Errorf("cannot prune unmerged worktrees without -f/--force: %s", strings.Join(unprunable, ", "))
}
}

if dryRun {
out.Println("Would remove:")
for _, wt := range toRemove {
l.Printf(" %s:%s (%s)\n", wt.RepoName, wt.Branch, wt.Path)
out.Printf(" %s:%s (%s)\n", wt.RepoName, wt.Branch, wt.Path)
}
return nil
}

// Use pruneWorktrees for consistent removal logic
// Force=true for git worktree remove: we already validated merge status above,
// or the user explicitly passed --force.
opts.Force = true
opts.PRCache = prCache
removed, failed := pruneWorktrees(ctx, toRemove, opts)

for _, wt := range removed {
Expand All @@ -368,13 +406,24 @@ func runPruneTargets(ctx context.Context, reg *registry.Registry, targets []stri
l.Printf("Failed to remove: %s:%s (%s)\n", wt.RepoName, wt.Branch, wt.Path)
}

// Save PR cache (entries may have been deleted during removal)
if err := prCache.SaveIfDirty(); err != nil {
l.Printf("Warning: failed to save cache: %v\n", err)
}

if len(failed) > 0 {
return fmt.Errorf("failed to remove %d worktree(s)", len(failed))
}

return nil
}

// isWorktreePrunable returns true if the worktree is safe to prune without force
// (merged via PR or merged locally into the default branch).
func isWorktreePrunable(wt git.Worktree) bool {
return wt.PRState == forge.PRStateMerged || wt.LocallyMerged
}

// pruneWorktrees removes the given worktrees and runs hooks.
// Returns slices of successfully removed and failed worktrees.
func pruneWorktrees(ctx context.Context, toRemove []git.Worktree, opts pruneOpts) (removed, failed []git.Worktree) {
Expand Down Expand Up @@ -470,8 +519,6 @@ func pruneWorktrees(ctx context.Context, toRemove []git.Worktree, opts pruneOpts
// Force delete if forge confirmed merge (handles squash merges),
// safe delete (-d) otherwise (including locally-merged branches,
// where git's own ancestry check in -d provides a safety net).
// Note: PRState is empty for targeted prune (no forge lookup),
// so forceDelete is always false in that path — this is intentional.
forceDelete := wt.PRState == forge.PRStateMerged
if err := git.DeleteLocalBranch(ctx, wt.RepoPath, wt.Branch, forceDelete); err != nil {
l.Printf("Warning: failed to delete branch %s: %v\n", wt.Branch, err)
Expand Down
192 changes: 192 additions & 0 deletions cmd/wt/prune_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1758,3 +1758,195 @@ func TestPrune_ExplicitHookFlag(t *testing.T) {
t.Error("default hook should NOT have run when --hook is used")
}
}

// TestPrune_MergedBranch_NoForceRequired tests that merged branches can be
// pruned without --force.
//
// Scenario: User runs `wt prune feature` where feature is merged into main
// Expected: Worktree is removed without needing -f
func TestPrune_MergedBranch_NoForceRequired(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
tmpDir = resolvePath(t, tmpDir)

repoPath := setupTestRepo(t, tmpDir, "test-repo")

// Create feature branch with a commit, then merge it into main
if _, err := runGitCommand(repoPath, "checkout", "-b", "feature"); err != nil {
t.Fatalf("failed to create feature branch: %v", err)
}
addCommit(t, repoPath, "feature.txt", "feature commit")

// Merge feature into main
if _, err := runGitCommand(repoPath, "checkout", "main"); err != nil {
t.Fatalf("failed to checkout main: %v", err)
}
if _, err := runGitCommand(repoPath, "merge", "feature", "--no-ff", "-m", "Merge feature"); err != nil {
t.Fatalf("failed to merge feature: %v", err)
}

// Create worktree for the (now merged) feature branch
wtPath := createTestWorktree(t, repoPath, "feature")

regFile := filepath.Join(tmpDir, ".wt", "repos.json")
os.MkdirAll(filepath.Dir(regFile), 0755)

reg := &registry.Registry{
Repos: []registry.Repo{
{Name: "test-repo", Path: repoPath},
},
}
if err := reg.Save(regFile); err != nil {
t.Fatalf("failed to save registry: %v", err)
}

cfg := &config.Config{RegistryPath: regFile}
ctx := testContextWithConfig(t, cfg, repoPath)

cmd := newPruneCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"feature"}) // No -f flag

if err := cmd.Execute(); err != nil {
t.Fatalf("prune should succeed without -f for merged branch: %v", err)
}

// Verify worktree was removed
if _, err := os.Stat(wtPath); err == nil {
t.Error("worktree should be removed after prune")
}
}

// TestPrune_UnmergedBranch_RequiresForce tests that unmerged branches require
// --force for targeted prune.
//
// Scenario: User runs `wt prune feature` where feature has unique commits
// Expected: Error listing the unmerged branch, suggesting -f
func TestPrune_UnmergedBranch_RequiresForce(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
tmpDir = resolvePath(t, tmpDir)

repoPath := setupTestRepoWithBranches(t, tmpDir, "test-repo", []string{"feature"})
wtPath := createTestWorktree(t, repoPath, "feature")

// Add a commit on the feature branch so it's not merged
addCommit(t, wtPath, "feature-only.txt", "unmerged commit")

regFile := filepath.Join(tmpDir, ".wt", "repos.json")
os.MkdirAll(filepath.Dir(regFile), 0755)

reg := &registry.Registry{
Repos: []registry.Repo{
{Name: "test-repo", Path: repoPath},
},
}
if err := reg.Save(regFile); err != nil {
t.Fatalf("failed to save registry: %v", err)
}

cfg := &config.Config{RegistryPath: regFile}
ctx := testContextWithConfig(t, cfg, repoPath)

cmd := newPruneCmd()
cmd.SetContext(ctx)
cmd.SetArgs([]string{"feature"}) // No -f flag

err := cmd.Execute()
if err == nil {
t.Fatal("prune should fail without -f for unmerged branch")
}
if !strings.Contains(err.Error(), "cannot prune unmerged worktrees without -f/--force") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "test-repo:feature") {
t.Fatalf("error should list the unmerged branch: %v", err)
}

// Verify worktree was NOT removed
if _, err := os.Stat(wtPath); os.IsNotExist(err) {
t.Error("worktree should not be removed when force is missing")
}
}

// TestPrune_MixedTargets_RequiresForce tests that mixed merged/unmerged
// targets require --force, listing only the unmerged ones.
//
// Scenario: User runs `wt prune merged unmerged` where only one is merged
// Expected: Error listing only the unmerged branch
func TestPrune_MixedTargets_RequiresForce(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
tmpDir = resolvePath(t, tmpDir)

repoPath := setupTestRepo(t, tmpDir, "test-repo")

// Create and merge the "merged" branch
if _, err := runGitCommand(repoPath, "checkout", "-b", "merged"); err != nil {
t.Fatalf("failed to create merged branch: %v", err)
}
addCommit(t, repoPath, "merged.txt", "merged commit")

if _, err := runGitCommand(repoPath, "checkout", "main"); err != nil {
t.Fatalf("failed to checkout main: %v", err)
}
if _, err := runGitCommand(repoPath, "merge", "merged", "--no-ff", "-m", "Merge merged"); err != nil {
t.Fatalf("failed to merge merged branch: %v", err)
}

// Create the "unmerged" branch with a unique commit
if _, err := runGitCommand(repoPath, "checkout", "-b", "unmerged"); err != nil {
t.Fatalf("failed to create unmerged branch: %v", err)
}
addCommit(t, repoPath, "unmerged.txt", "unmerged commit")

// Switch back to main before creating worktrees
if _, err := runGitCommand(repoPath, "checkout", "main"); err != nil {
t.Fatalf("failed to checkout main: %v", err)
}

// Create worktrees for both branches
mergedWtPath := createTestWorktree(t, repoPath, "merged")
unmergedWtPath := createTestWorktree(t, repoPath, "unmerged")

regFile := filepath.Join(tmpDir, ".wt", "repos.json")
os.MkdirAll(filepath.Dir(regFile), 0755)

reg := &registry.Registry{
Repos: []registry.Repo{
{Name: "test-repo", Path: repoPath},
},
}
if err := reg.Save(regFile); err != nil {
t.Fatalf("failed to save registry: %v", err)
}

cfg := &config.Config{RegistryPath: regFile}
ctx := testContextWithConfig(t, cfg, repoPath)

pruneCmd := newPruneCmd()
pruneCmd.SetContext(ctx)
pruneCmd.SetArgs([]string{"merged", "unmerged"}) // No -f flag

err := pruneCmd.Execute()
if err == nil {
t.Fatal("prune should fail when any target is unmerged")
}
if !strings.Contains(err.Error(), "test-repo:unmerged") {
t.Fatalf("error should list the unmerged branch: %v", err)
}
if strings.Contains(err.Error(), "test-repo:merged") {
t.Fatalf("error should NOT list the merged branch: %v", err)
}

// Verify neither worktree was removed (error returned before any removal)
if _, err := os.Stat(mergedWtPath); os.IsNotExist(err) {
t.Error("merged worktree should not be removed on error")
}
if _, err := os.Stat(unmergedWtPath); os.IsNotExist(err) {
t.Error("unmerged worktree should not be removed on error")
}
}
Loading