The guard this branch added was solving the wrong problem. Detaching a worktree's HEAD severs the only link git records between it and the branch, so the branch really has no worktree and deleting the ref alone is the correct operation — and it never lost anything: `SafeDelete` retains an unintegrated branch, so the only refs it deleted were ones worktrunk's own integration test calls lossless, and even a forced `-D` leaves the commits reachable through the detached worktree's HEAD, which is a GC root. What #3769 actually reported was silence. `○ No worktree found for branch <name>` is true and still reads as "nothing is there" while a directory sits at exactly that path, so the removal now names it and the `wt remove <path>` that clears it, on stderr and in `--format=json`'s new `detached_worktree`. Refusing instead cost a legitimate branch cleanup and let a branch address a detached worktree — the one thing the worktree model says a branch cannot name. `detached_worktree_for` survives to find the path; it no longer decides whether the command runs, which is also why the guard-scoping it needed (a branch-existence check, a `deletion_mode` gate) goes with it: an extra line of output is harmless where a refusal had to be narrowed case by case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
+++ title = "wt remove" description = "Remove worktree; delete branch if merged. Defaults to the current worktree." weight = 12
[extra] group = "Commands" +++
Remove worktree; delete branch if merged. Defaults to the current worktree.
Examples
Remove current worktree:
{% terminal(cmd="wt remove") %} ◎ Running pre-remove project:cleanup flyctl scale count 0 Scaling app to 0 machines ◎ Removing api worktree & branch in background (same commit as main, _) ○ Switched to worktree for main @ ~/repo {% end %}
Remove specific worktrees / branches:
{{ terminal(cmd="wt remove feature-branch|||wt remove old-feature another-branch") }}
Keep the branch:
{{ terminal(cmd="wt remove --no-delete-branch feature-branch") }}
Force-delete an unmerged branch:
{{ terminal(cmd="wt remove -D experimental") }}
Branch cleanup
By default, branches are deleted when they would add no changes to the default branch if merged. This works with both unchanged git histories, and squash-merge or rebase workflows where commit history differs but file changes match.
Worktrunk checks six conditions (in order of cost):
- Same commit — Branch HEAD equals the default branch. Shows
_inwt list. - Ancestor — Branch is in target's history (fast-forward or rebase case). Shows
⊂. - No added changes — Three-dot diff (
target...branch) is empty. Shows⊂. - Trees match — Branch tree SHA equals target tree SHA. Shows
⊂. - Merge adds nothing — Simulated merge produces the same tree as target. Handles squash-merged branches where target has advanced with changes to different files. Shows
⊂. - Patch-id match — Branch's entire diff matches a single squash-merge commit on target. Fallback for when the simulated merge conflicts because target later modified the same files the branch touched. Shows
⊂.
The default-branch walk is capped so a single check stays fast; a squash merge with hundreds of commits landed since the merge point falls outside the cap and needs -D to remove.
The 'same commit' check uses the local default branch; for other checks, 'target' means the default branch, or its upstream (e.g., origin/main) when strictly ahead.
Branches matching these conditions and with empty working trees are dimmed in wt list as safe to delete.
Those six ask whether deleting loses work. A branch checked out in a second worktree (only reachable via git worktree add --force) fails a different test: deleting the ref would leave that worktree unable to resolve HEAD, which is why git branch -d refuses the same delete. Such a branch is retained whatever -D asks, and the surviving checkout is named.
Detaching a worktree's HEAD severs the only link git records between it and the branch, so the branch has no worktree from then on and wt remove <branch> deletes the ref alone. The directory stays where it is, and the removal names it and the wt remove <path> that clears it.
Force flags
Worktrunk has two force flags for different situations:
| Flag | Scope | When to use |
|---|---|---|
--force (-f) |
Worktree | Worktree has uncommitted changes |
--force-delete (-D) |
Branch | Branch has unmerged commits |
{{ terminal(cmd="wt remove feature --force # Remove dirty worktree|||wt remove feature -D # Delete unmerged branch|||wt remove feature --force -D # Both") }}
Use --no-delete-branch to keep the branch regardless of merge status.
Background removal
Removal runs in the background by default — the command returns immediately. The worktree is renamed into .git/wt/trash/ (instant same-filesystem rename), git metadata is pruned, the branch is deleted, and a detached rm -rf finishes cleanup. Cross-filesystem worktrees fall back to git worktree remove. Logs: .git/wt/logs/{branch}/internal/remove.log. Use --foreground to run in the foreground.
After each wt remove, entries in .git/wt/trash/ older than 24 hours are swept by a detached rm -rf — eventual cleanup for directories orphaned when a previous background removal was interrupted (SIGKILL, reboot, disk full).
Reaping processes
--reap terminates processes left running in the worktree before it is removed — a post-start dev server, a file watcher, a language server — freeing the ports and file handles they hold. Processes are discovered by working directory: any process whose current directory is at or under the worktree path (SIGTERM, then SIGKILL for survivors).
{% terminal(cmd="wt remove --reap feature") %} ◎ Reaping 2 processes under feature worktree ┃ 51234 node ┃ 51240 esbuild ✓ Reaped 2 processes ◎ Removing feature worktree & branch in background (same commit as main, _) {% end %}
To avoid killing work the user did not mean to kill, two guards keep --reap conservative:
- Interactive processes are spared. A process holding a controlling terminal — an interactive shell, or a terminal editor such as
vimwith unsaved buffers — is never reaped. Only detached processes remain candidates. - Discovery is by working directory only. A process that started in the worktree and later changed directory, or a daemon that reparented to
init, no longer reports a directory under the worktree and is not found. To reliably reap those, launch them withwt step tether, which kills the whole process group when the worktree is removed.
Reaping runs before the worktree directory is touched, so it is independent of foreground/background removal and the --force flag. Unix only; on Windows --reap is rejected.
JSON output
--format=json prints one object per removal to stdout: {kind, branch, path, branch_outcome, branch_checked_out_at} for a worktree, with pruned in place of path for a branch-only removal, plus detached_worktree — the directory left at that branch's path with a detached HEAD, which the branch no longer names and this removal therefore leaves alone.
branch_outcome names what happened to the branch, so a caller can tell a deletion the removal declined from one it was never asked to make:
| Value | Meaning |
|---|---|
deleted |
The branch is gone |
deferred |
Handed to the detached background process, whose result this run never sees. --foreground never reports it |
not_attempted |
No deletion was tried: a detached worktree, a sibling checkout, or --no-delete-branch |
retained_unmerged |
Declined: the branch was not integrated into the target |
retained_checked_out |
Declined: the final topology read found a live worktree with it checked out |
retained_raced |
Refused by the compare-and-swap — the branch moved between the integration check and the delete. Re-read the ref and retry |
retained_failed |
The delete command itself failed |
Hooks
pre-remove hooks run before the worktree is deleted (with access to worktree files). post-remove hooks run after removal. See wt hook for configuration.
Detached HEAD worktrees
Detached worktrees have no branch name. Pass the worktree path instead: wt remove /path/to/worktree.
See also
Command reference
{% terminal() %} wt remove - Remove worktree; delete branch if merged
Defaults to the current worktree.
Usage: wt remove [OPTIONS] [BRANCHES]...
Arguments: [BRANCHES]... Branch name or worktree path [default: current]
Options: --no-delete-branch Keep branch after removal
-D, --force-delete Delete unmerged branches
<b><span class=c>--foreground</span></b>
Run removal in foreground (block until complete)
<b><span class=c>--reap</span></b>
Kill processes started in the worktree [experimental]
Before removal, terminate processes whose working directory is under the worktree — dev
servers, watchers, language servers. Processes holding a controlling terminal (interactive
shells, terminal editors) are left alone. Unix only.
-f, --force Force worktree removal
Remove a dirty worktree, including staged, modified, and untracked files. Without this
flag, removal fails if the worktree has any uncommitted changes.
-h, --help Print help (see a summary with '-h')
Automation: --no-hooks Skip hooks
<b><span class=c>--format</span></b><span class=c> <FORMAT></span>
Output format
JSON prints structured result to stdout after removal completes.
[default: text]
[possible values: text, json]
Global Options: -C <path> Working directory for this command
<b><span class=c>--config</span></b><span class=c> <path></span>
User config file path
<b><span class=c>--config-set</span></b><span class=c> <toml></span>
Override config with inline TOML, e.g. --config-set list.full=true (repeatable)
-v, --verbose... Verbose output (-v: info logs + hook/alias template variables on stderr; -vv: also debug logs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 to apply the same level everywhere — including shell completion, which no flag can reach
-y, --yes Skip approval prompts {% end %}