Files
max-sixty__worktrunk/docs/content/remove.md
Maximilian Roos 5c42c5b7d5 feat: machine-readable approval state and branch-removal outcomes (#3710)
Two of the five machine-readable-output requests NathanaelRea opened
(#3696–#3700), reviewed as a set and implemented where the gap was real.

## `wt config approvals list --format=json` (#3698)

The command already computed the four distinctions an orchestrator needs
— no commands, approved, approval-required, and stale — read-only,
without prompting or writing. It had no `--format` flag, so the only way
to learn that a non-interactive run would stop for approval was to run
the operation and catch `NotInteractive`, or to pass `--yes` and approve
whatever was there.

```json
{
  "state": "approval_required",
  "commands": [
    {"phase": "post-start", "name": "dev", "template": "npm run dev", "approved": false},
    {"phase": "pre-merge", "template": "cargo test", "approved": true}
  ],
  "stale": ["some removed command"]
}
```

`state` is what a caller branches on. `stale` stays a separate list
rather than a fourth `state`, because it co-occurs with all three — and
those are the approvals `--yes` would silently re-approve after their
command template changed, which is exactly what an orchestrator
preserving the approval model needs to see.

A flag on the existing read command rather than a new `status` verb,
matching `wt config show`, `wt config state get`, `wt config state
logs`, and `wt list`.

Closes #3698.

## `branch_outcome` on removal (#3700, partly)

`wt remove --format=json` reported the branch as one boolean, collapsing
five internal outcomes into two values:

| Internal outcome | `branch_deleted` was |
|---|---|
| `Deleted` | `true` |
| `Deferred` — handed to a detached process, result never observed |
`true` |
| `NotAttempted` — no branch, or `--no-delete-branch` | `false` |
| `Retained` — a sibling worktree has it checked out | `false` |
| `Retained` — **the CAS refused; the ref moved under us** | `false` |

The last row is the exact race #3700 asks for protection against.
Worktrunk already deletes with `git update-ref -d <ref> <oid>` and
already fails closed when the ref has moved — then reported it as the
same `false` that means "you asked me not to". And `Deferred` reported
`true` on intent.

`branch_outcome` names it instead: `deleted`, `deferred`,
`not_attempted`, `retained_unmerged`, `retained_checked_out`,
`retained_raced`, `retained_failed`. A caller that sees `retained_raced`
knows to re-read the ref and retry, which is what the guard detects it
for.

**This does not close #3700.** That issue asks for an *input* — a
caller-supplied expected OID that makes `wt` fail closed against the
orchestrator's own observation. This is an *output*. They land in the
same place on the default path, because the integration check already
refuses to delete unintegrated content, so the caller was never going to
lose commits — they just couldn't classify the refusal. Where the gap is
real is `--force-delete` / `-D`, which takes the early return in
`delete_branch_if_safe` and runs `git branch -D` with no integration
check and no CAS. If an `--expected-oid` flag lands, it has to gate that
path.

## Notes

- **Output-format break.** `branch_deleted` is replaced, not
supplemented, on `wt remove --format=json` and on `wt step prune
--format=json`'s live path. Per CLAUDE.md, output formatting is on the
flexible side of the interface line; flagging it here so the release
changelog picks it up.
- **`wt step prune --dry-run` keeps `branch_deleted`.** A dry run
predicts; it runs nothing to have an outcome. Different thing, different
name, documented as such.
- **`retained_raced` and `retained_checked_out` have no deterministic
CLI trigger.** Both come from windows between `wt`'s own fresh read and
the ref mutation, which no hook can be scheduled inside. They're covered
at the unit level (`branch_fate_from_result_mapping`,
`branch_fate_json_outcome_is_distinct_per_fate`, and
`cas_rejects_delete_when_branch_advances` in `src/git/remove.rs`, which
drives the race with a stale snapshot). The integration tests cover the
two reachable contrasts: `retained_unmerged` via a `pre-remove` hook
that commits, and `not_attempted` via `--no-delete-branch`.
- **`print_json` lives under `src/commands/list/`** and now has a third
caller from outside that module. Worth a more central home; not moved
here.

## The other three

Reviewed but not implemented:

- **#3696** — already possible. `wt --config-set 'list.json-schema = 2'
list --format=json` pins the schema per invocation above every config
layer, as does `WORKTRUNK_LIST__JSON_SCHEMA`. Answered on the issue;
what's left is a docs gap and making an out-of-range value fail rather
than degrade in JSON mode.
- **#3697** — the machine-readable error channel. A real gap and the one
policy call in the set; not started.
- **#3699** — aimed at `wt config state logs --format=json`, which is a
directory listing reconstructed from paths, under a model that
overwrites. The append-only run record it wants is `commands.jsonl`.

## Testing

`cargo run -- hook pre-merge --yes` green: 4533 tests, clippy, fmt,
doctests, rustdoc, docs sync.

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:17:27 -07:00

9.9 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.

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.

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 %}