Files
max-sixty__worktrunk/docs/content/remove.md
Maximilian Roos acdbbb6824 fix(remove): name the detached worktree instead of refusing the removal
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>
2026-08-11 07:47:21 -07:00

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):

  1. Same commit — Branch HEAD equals the default branch. Shows _ in wt list.
  2. Ancestor — Branch is in target's history (fast-forward or rebase case). Shows ⊂.
  3. No added changes — Three-dot diff (target...branch) is empty. Shows ⊂.
  4. Trees match — Branch tree SHA equals target tree SHA. Shows ⊂.
  5. 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 ⊂.
  6. 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 vim with 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 with wt 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

  • wt merge — Remove worktree after merging
  • wt list — View all worktrees

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> &lt;FORMAT&gt;</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> &lt;path&gt;</span>
      User config file path

  <b><span class=c>--config-set</span></b><span class=c> &lt;toml&gt;</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 %}