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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions cmd/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -201,7 +202,8 @@ Available variables:
cd "$(wt cd <name>)"
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.
Expand Down
92 changes: 78 additions & 14 deletions cmd/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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())

Expand All @@ -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 {
Expand All @@ -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
}

Expand All @@ -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),
})
}
}
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}
59 changes: 59 additions & 0 deletions cmd/prune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
52 changes: 52 additions & 0 deletions e2e/testdata/prune.txtar
Original file line number Diff line number Diff line change
@@ -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