From a552f5e01e6f5cc49eb911a722aab34ce481b5fe Mon Sep 17 00:00:00 2001 From: Brian Kildow Date: Fri, 4 Sep 2026 15:43:46 -0400 Subject: [PATCH] Let wt prune remove dirty merged worktrees with --force Prune tried `git worktree remove` without --force on every merged candidate, so a merged worktree with uncommitted changes failed with a warning after the user had already confirmed and after teardown hooks had run. Prune now checks each merged candidate for uncommitted changes up front and shows a STATUS column (clean/dirty). Dirty worktrees are kept unless --force is given, and are skipped before teardown hooks run. The confirmation count only includes worktrees that will actually be removed. --force changes meaning to match `wt remove --force`: it removes merged worktrees even when they have uncommitted changes, and is forwarded to git so worktrees git considers unclean for other reasons (submodules) are removed too. A new --yes flag takes over skipping the confirmation prompt. Help text, README, and the agents guide are updated accordingly. Claude-Session: https://claude.ai/code/session_01VrT5beDfJZ5EwsN3RDCCS3 --- README.md | 5 ++- cmd/agents.go | 8 ++-- cmd/prune.go | 92 ++++++++++++++++++++++++++++++++++------ cmd/prune_test.go | 59 ++++++++++++++++++++++++++ e2e/testdata/prune.txtar | 52 +++++++++++++++++++++++ 5 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 e2e/testdata/prune.txtar diff --git a/README.md b/README.md index db5ea48..3310cbd 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,11 @@ Skips dirty worktrees. Shows summary of updated/skipped/failed counts. ```bash wt prune # Remove worktrees with merged branches -wt prune --force # Skip confirmation +wt prune --force # Also remove merged worktrees with uncommitted changes +wt prune --yes # Skip confirmation ``` -Compares branches against the default branch (main/master). +Compares branches against the default branch (main/master). Detects regular, squash, and rebase merges, plus merged pull requests when `gh` is available. Merged worktrees with uncommitted changes are listed as `dirty` and kept unless you pass `--force`. ### wt agents diff --git a/cmd/agents.go b/cmd/agents.go index 074212a..61e9064 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -108,7 +108,8 @@ After wt init (existing repo): ### Remove worktrees with merged branches - wt prune --force # Use --force to skip confirmation + wt prune --yes # Use --yes to skip confirmation + wt prune --force --yes # Also remove merged worktrees with uncommitted changes ### Preview any command safely @@ -148,7 +149,7 @@ After wt init (existing repo): ### Cleaning up after merge wt sync - wt prune --force + wt prune --yes ### Applying shared file changes @@ -201,7 +202,8 @@ Available variables: cd "$(wt cd )" 2. For cloned projects, there is no .git at the project root (bare repo at .bare/). For initialized projects, .git exists and the project root is the main worktree. -3. Use --force with wt remove and wt prune to skip interactive confirmation. +3. Use --force with wt remove and --yes with wt prune to skip interactive confirmation. + wt prune --force removes merged worktrees even when they have uncommitted changes. 4. Use --dry-run to safely preview any destructive operation. 5. The project root is identified by .worktree.yml — look for this file. 6. Run git commands inside the worktree directory, not the project root. diff --git a/cmd/prune.go b/cmd/prune.go index 5e6aafe..081e6c7 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -17,10 +17,17 @@ func newPruneCmd() *cobra.Command { cmd := &cobra.Command{ Use: "prune", Short: "Remove worktrees with fully merged branches", - Args: cobra.NoArgs, - RunE: runPrune, + Long: `Remove worktrees whose branch has been merged into the default branch. + +Detects regular, squash, and rebase merges, plus merged pull requests when +gh is available. Merged worktrees with uncommitted changes are listed as +dirty and kept unless --force is given. A confirmation prompt is shown +before anything is removed; pass --yes to skip it.`, + Args: cobra.NoArgs, + RunE: runPrune, } - cmd.Flags().Bool("force", false, "Skip confirmation prompt") + cmd.Flags().Bool("force", false, "Remove merged worktrees even if they have uncommitted changes") + cmd.Flags().Bool("yes", false, "Skip confirmation prompt") cmd.Flags().Bool("skip-teardown", false, "Skip running teardown hooks before removing worktrees") return cmd } @@ -31,6 +38,28 @@ type prunable struct { worktree git.WorktreeInfo method git.MergeMethod reason string + dirty bool +} + +// status describes the working tree for the candidate listing. +func (p prunable) status() string { + if p.dirty { + return "dirty" + } + return "clean" +} + +// partitionPrunable splits candidates into those prune will remove and those +// it will keep. Only dirty worktrees are ever kept, and only without --force. +func partitionPrunable(candidates []prunable, force bool) (remove, keep []prunable) { + for _, p := range candidates { + if p.dirty && !force { + keep = append(keep, p) + continue + } + remove = append(remove, p) + } + return remove, keep } func mergeReason(method git.MergeMethod) string { @@ -85,6 +114,10 @@ func runPrune(cmd *cobra.Command, args []string) error { return err } + force, _ := cmd.Flags().GetBool("force") + yes, _ := cmd.Flags().GetBool("yes") + skipTeardown, _ := cmd.Flags().GetBool("skip-teardown") + cwd, _ := os.Getwd() runner := git.NewRunner(project.GitDirPath(projectRoot, cfg), IsDryRun()) @@ -107,6 +140,17 @@ func runPrune(cmd *cobra.Command, args []string) error { f = forge.Detect(ctx, remoteURL) } + // A worktree's dirtiness decides whether it is removed, so an unanswerable + // question is treated as dirty rather than risking uncommitted work. + isDirty := func(wt git.WorktreeInfo) bool { + dirty, err := runner.IsWorktreeDirty(ctx, wt.Path) + if err != nil { + ui.Warning(fmt.Sprintf("%s: could not check for uncommitted changes, assuming dirty: %s", wt.Branch, err)) + return true + } + return dirty + } + var pruneable []prunable for _, wt := range filtered { if wt.Branch == defaultBranch { @@ -124,7 +168,12 @@ func runPrune(cmd *cobra.Command, args []string) error { } if status.Merged { - pruneable = append(pruneable, prunable{worktree: wt, method: status.Method, reason: mergeReason(status.Method)}) + pruneable = append(pruneable, prunable{ + worktree: wt, + method: status.Method, + reason: mergeReason(status.Method), + dirty: isDirty(wt), + }) continue } @@ -150,6 +199,7 @@ func runPrune(cmd *cobra.Command, args []string) error { worktree: wt, method: git.MergeNone, reason: fmt.Sprintf("merged (PR #%d)", pr.Number), + dirty: isDirty(wt), }) } } @@ -160,20 +210,32 @@ func runPrune(cmd *cobra.Command, args []string) error { } ui.Step("Merged worktrees:") - t := ui.NewTable().Headers("BRANCH", "PATH", "MERGED") + t := ui.NewTable().Headers("BRANCH", "PATH", "MERGED", "STATUS") for _, p := range pruneable { relPath, err := filepath.Rel(projectRoot, p.worktree.Path) if err != nil { relPath = p.worktree.Path } - t.Row(p.worktree.Branch, relPath, p.reason) + t.Row(p.worktree.Branch, relPath, p.reason, p.status()) } ui.PrintTable(t) - force, _ := cmd.Flags().GetBool("force") - if !force && !IsDryRun() { + toRemove, kept := partitionPrunable(pruneable, force) + if len(kept) > 0 { + ui.Warning(fmt.Sprintf( + "%d worktree(s) have uncommitted changes and will be kept. Pass --force to remove them too.", + len(kept), + )) + } + + if len(toRemove) == 0 { + ui.Info("Nothing to prune.") + return nil + } + + if !yes && !IsDryRun() { prompter := &ui.InteractivePrompter{} - confirmed, err := prompter.Confirm(fmt.Sprintf("Remove %d merged worktree(s)?", len(pruneable))) + confirmed, err := prompter.Confirm(fmt.Sprintf("Remove %d merged worktree(s)?", len(toRemove))) if err != nil { if ui.IsUserAbort(err) { return nil @@ -186,10 +248,8 @@ func runPrune(cmd *cobra.Command, args []string) error { } } - skipTeardown, _ := cmd.Flags().GetBool("skip-teardown") - var removed int - for _, p := range pruneable { + for _, p := range toRemove { wt := p.worktree if !skipTeardown { if err := project.RunTeardownHooks(ctx, cfg, wt.Path, IsDryRun()); err != nil { @@ -201,7 +261,7 @@ func runPrune(cmd *cobra.Command, args []string) error { } ui.Step("Removing worktree: " + wt.Branch) - if err := runner.WorktreeRemove(ctx, wt.Path, false); err != nil { + if err := runner.WorktreeRemove(ctx, wt.Path, force); err != nil { ui.Warning(fmt.Sprintf("Could not remove worktree %s: %s", wt.Branch, err)) continue } @@ -227,6 +287,10 @@ func runPrune(cmd *cobra.Command, args []string) error { ui.Warning("Could not prune worktree metadata: " + err.Error()) } - ui.Success(fmt.Sprintf("Pruned %d worktree(s)", removed)) + summary := fmt.Sprintf("Pruned %d worktree(s)", removed) + if len(kept) > 0 { + summary += fmt.Sprintf(", kept %d with uncommitted changes (use --force)", len(kept)) + } + ui.Success(summary) return nil } diff --git a/cmd/prune_test.go b/cmd/prune_test.go index 6bf854e..7d543b6 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -82,3 +82,62 @@ func TestMergeReason(t *testing.T) { }) } } + +// TestPartitionPrunable pins the --force contract: dirty merged worktrees are +// kept unless forced, clean ones are always removed. +func TestPartitionPrunable(t *testing.T) { + clean := prunable{worktree: git.WorktreeInfo{Branch: "clean"}} + dirty := prunable{worktree: git.WorktreeInfo{Branch: "dirty"}, dirty: true} + candidates := []prunable{clean, dirty} + + tests := []struct { + name string + force bool + wantRemove []string + wantKeep []string + }{ + {"without --force dirty is kept", false, []string{"clean"}, []string{"dirty"}}, + {"with --force everything goes", true, []string{"clean", "dirty"}, nil}, + } + + branches := func(ps []prunable) []string { + var out []string + for _, p := range ps { + out = append(out, p.worktree.Branch) + } + return out + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remove, keep := partitionPrunable(candidates, tt.force) + if got := branches(remove); !equalStrings(got, tt.wantRemove) { + t.Errorf("remove = %v, want %v", got, tt.wantRemove) + } + if got := branches(keep); !equalStrings(got, tt.wantKeep) { + t.Errorf("keep = %v, want %v", got, tt.wantKeep) + } + }) + } +} + +func TestPrunableStatus(t *testing.T) { + if got := (prunable{}).status(); got != "clean" { + t.Errorf("status() = %q, want clean", got) + } + if got := (prunable{dirty: true}).status(); got != "dirty" { + t.Errorf("status() = %q, want dirty", got) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/e2e/testdata/prune.txtar b/e2e/testdata/prune.txtar new file mode 100644 index 0000000..c84f134 --- /dev/null +++ b/e2e/testdata/prune.txtar @@ -0,0 +1,52 @@ +[!exec:git] skip 'git not available' + +# The remote's default branch is master, so point prune at it. `develop` is +# created at the same commit as master and is therefore already merged. +setup-repo develop feature +setup-project + +cd $WORK/project +cp $WORK/config.yml .worktree.yml + +exec git --git-dir=.bare worktree add --relative-paths worktrees/develop develop +exec git --git-dir=.bare worktree add --relative-paths worktrees/feature feature + +# Dirty the develop worktree. +cp $WORK/scratch.txt worktrees/develop/scratch.txt + +# Without --force the dirty worktree is listed but kept; the clean one goes. +exec wt prune --yes +stderr 'develop.*merged.*dirty' +stderr 'feature.*merged.*clean' +stderr 'uncommitted changes and will be kept' +stderr 'Removing worktree: feature' +! stderr 'Removing worktree: develop' +stderr 'Pruned 1 worktree\(s\), kept 1 with uncommitted changes' +exists worktrees/develop/scratch.txt +! exists worktrees/feature + +# Nothing removable left without --force: no prompt, no removal. +exec wt prune --yes +stderr 'Nothing to prune' +exists worktrees/develop/scratch.txt + +# Dry run never removes anything, forced or not. +exec wt --dry-run prune --force +stderr 'develop.*merged.*dirty' +exists worktrees/develop/scratch.txt + +# --force removes the dirty merged worktree. +exec wt prune --force --yes +stderr 'Removing worktree: develop' +stderr 'Pruned 1 worktree\(s\)' +! stderr 'kept' +! exists worktrees/develop + +-- config.yml -- +version: 1 +git_dir: .bare +worktree_dir: worktrees +shared_dir: shared +main_branch: master +-- scratch.txt -- +uncommitted