mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
5c42c5b7d5
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>
199 lines
9.9 KiB
Markdown
199 lines
9.9 KiB
Markdown
+++
|
|
title = "wt remove"
|
|
description = "Remove worktree; delete branch if merged. Defaults to the current worktree."
|
|
weight = 12
|
|
|
|
[extra]
|
|
group = "Commands"
|
|
+++
|
|
|
|
<!-- ⚠️ AUTO-GENERATED from `wt remove --help-page` — edit src/cli/mod.rs to update -->
|
|
|
|
Remove worktree; delete branch if merged. Defaults to the current worktree.
|
|
|
|
## Examples
|
|
|
|
Remove current worktree:
|
|
|
|
{% terminal(cmd="wt remove") %}
|
|
<span class=c>◎</span> <span class=c>Running pre-remove <b>project:cleanup</b></span>
|
|
<span style='background:var(--bright-white,#fff)'> </span> <span class=d><span style='color:var(--blue,#00a)'>flyctl</span></span><span class=d> scale count 0</span>
|
|
Scaling app to 0 machines
|
|
<span class=c>◎</span> <span class=c>Removing <b>api</b> worktree & branch in background (same commit as <b>main</b>,</span> <span class=d>_</span><span class=c>)</span>
|
|
<span class=d>○</span> Switched to worktree for <b>main</b> @ <b>~/repo</b>
|
|
{% 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
|
|
|
|
<span class="badge-experimental"></span>
|
|
|
|
`--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`](@/step.md#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`](@/hook.md) 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`](@/merge.md) — Remove worktree after merging
|
|
- [`wt list`](@/list.md) — View all worktrees
|
|
|
|
## Command reference
|
|
|
|
{% terminal() %}
|
|
wt remove - Remove worktree; delete branch if merged
|
|
|
|
Defaults to the current worktree.
|
|
|
|
Usage: <b><span class=c>wt remove</span></b> <span class=c>[OPTIONS]</span> <span class=c>[BRANCHES]...</span>
|
|
|
|
<b><span class=g>Arguments:</span></b>
|
|
<span class=c>[BRANCHES]...</span>
|
|
Branch name or worktree path [default: current]
|
|
|
|
<b><span class=g>Options:</span></b>
|
|
<b><span class=c>--no-delete-branch</span></b>
|
|
Keep branch after removal
|
|
|
|
<b><span class=c>-D</span></b>, <b><span class=c>--force-delete</span></b>
|
|
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.
|
|
|
|
<b><span class=c>-f</span></b>, <b><span class=c>--force</span></b>
|
|
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.
|
|
|
|
<b><span class=c>-h</span></b>, <b><span class=c>--help</span></b>
|
|
Print help (see a summary with '-h')
|
|
|
|
<b><span class=g>Automation:</span></b>
|
|
<b><span class=c>--no-hooks</span></b>
|
|
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]
|
|
|
|
<b><span class=g>Global Options:</span></b>
|
|
<b><span class=c>-C</span></b><span class=c> <path></span>
|
|
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)
|
|
|
|
<b><span class=c>-v</span></b>, <b><span class=c>--verbose</span></b><span class=c>...</span>
|
|
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
|
|
|
|
<b><span class=c>-y</span></b>, <b><span class=c>--yes</span></b>
|
|
Skip approval prompts
|
|
{% end %}
|
|
|
|
<!-- END AUTO-GENERATED -->
|