diff --git a/cmd/wt/prune_cmd.go b/cmd/wt/prune_cmd.go index 491020a..7a9db49 100644 --- a/cmd/wt/prune_cmd.go +++ b/cmd/wt/prune_cmd.go @@ -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 { @@ -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 @@ -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, @@ -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") @@ -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) @@ -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 { @@ -368,6 +406,11 @@ 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)) } @@ -375,6 +418,12 @@ func runPruneTargets(ctx context.Context, reg *registry.Registry, targets []stri 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) { @@ -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) diff --git a/cmd/wt/prune_integration_test.go b/cmd/wt/prune_integration_test.go index c591177..52dd508 100644 --- a/cmd/wt/prune_integration_test.go +++ b/cmd/wt/prune_integration_test.go @@ -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 := ®istry.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 := ®istry.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 := ®istry.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") + } +}