Repository navigation
feat: machine-readable approval state and branch-removal outcomes - #3710
Conversation
`wt config approvals list` 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.
`--format=json` emits `{state, commands, stale}`. `stale` stays a separate
list rather than a fourth `state` because it co-occurs with all three, and
because those are the approvals `--yes` would silently re-approve after their
command changed.
Closes #3698
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`wt remove --format=json` reported the branch as `branch_deleted: bool`, which collapsed five internal outcomes into two values. Worktrunk already deletes branches with a compare-and-swap and already detects a ref that moved under it (`RetainedRaced`) — then told the caller the same `false` that means "you asked me not to". `Deferred`, handed to a detached process whose result this run never sees, reported `true` on intent. `branch_outcome` names the outcome 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 fail-closed CAS detects it for. `BranchFate::Retained` carries the reason, and one `from_outcome` holds the mapping so the paths without a `Result` around the outcome cannot classify it differently. `wt step prune` reports the same field on its live path; `--dry-run` keeps `branch_deleted`, which is a prediction rather than an outcome, and is named accordingly. Refs #3700 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk-bot
left a comment
There was a problem hiding this comment.
Reviewed the full diff. The change is sound and well-tested — flagging one thing per repo policy rather than approving.
Data-loss surface (no-approve, not a defect). The diff edits src/output/handlers.rs, src/commands/step/prune.rs, and src/commands/worktree/types.rs, all of which carry the branch-deletion machinery (git branch -D, git worktree remove, the rm -rf background tail). Per this repo's review policy I don't auto-approve changes that touch the deletion surface — leaving the merge call to you.
That said, I verified the change is classification/reporting only and does not alter any deletion decision:
BranchFate::deleted()is unchanged (Deleted | Deferred → true), sodeletes_branch, the prune summary counts, and the dry-runbranch_deletedfield are all behavior-preserving.- The new
from_outcomecentralizes the exact mapping the two old inlinematchblocks (infrom_resultandhandle_branch_only_output) used —Integrated/ForceDeleted → Deleted,NotDeleted → Unmerged,RetainedCheckedOut → CheckedOut,RetainedRaced → Raced,Err → Failed— so no outcome reclassifies. - On the live prune JSON path, every candidate in
removedpasses throughrecord_fatebefore serialization (the tworemoved.pushsites and the deferred-current tail), soc.fate.map(|f| f.json_outcome())isSomeby construction; thenullfallback is unreachable there. Dry-run correctly keepsbranch_deletedsince it runs no removal.
The deferred → deferred and retained_raced distinctions are genuine honesty improvements over the old boolean, which reported a detached deletion as true and a CAS refusal as the same false a caller-requested retention gives.
Release v0.72.0 — 55 commits since v0.71.0. Minor bump: `cargo semver-checks` reports 5 breaking library changes, so patch is disallowed pre-1.0. ## Headline changes - **`wt merge` / `wt step push` no longer autostash the target worktree** (#3703). Both strategies now advance the target through one `advance_target` — a compare-and-swap `update-ref`, then `read-tree -m -u` in the target worktree — so `refs/stash` is never entered and staged changes stay staged. - **Forge classification returns to brand-in-hostname** (#3673), reverting the exact-DNS-label rule 0.71.0 shipped. `github-enterprise.acme.com` and friends resolve again with no config. - **`[projects."…"]` keys match by `*` pattern and carry forge settings** (#3701), so one user-config entry covers every repository on a self-hosted host. - **A published JSON Schema for `wt list --format=json` schema 2** (#3747), plus machine-readable approval state and `branch_outcome` (#3710). Full detail in `CHANGELOG.md`. ## One fix made during the release cut The release audit surfaced a gap this release's own `advance_target` rewrite introduced, fixed here rather than deferred: **`wt merge` / `wt step push` now refuse a target worktree parked mid-operation.** The target sync is a two-tree merge, which refuses an unmerged index but *not* a stopped cherry-pick or rebase whose conflict has already been staged. A target paused between steps could therefore have the push range written into it, and the user's `--continue` would commit the synced tree as the step's result. The old fast-forward path got this check for free from `receive.denyCurrentBranch=updateInstead`, which refused any unclean target outright; both strategies now ask directly, and the refusal names the worktree holding the operation. `test_push_refuses_target_mid_operation` covers it in both shapes a stopped operation can take, and both are mutation-verified. With the gate disabled, the push succeeds and writes `feature.txt` into the mid-cherry-pick worktree. The rebase case was added in response to review feedback on this PR, and pins a second dependency. A rebase detaches HEAD, so `git worktree list --porcelain` reports the target with no branch and `worktree_for_branch` finds it only because `finalize_worktree` backfills from `rebase-merge/head-name`. That makes the rebase arm the one place this guarantee rests on a helper of ours rather than on git — the fast-forward path it replaced got the refusal from `find_shared_symref`. With the backfill disabled, `wt step push` succeeds against a worktree parked mid-rebase while the cherry-pick case still passes, so the gap was real. ## Validation - Local gate green: `cargo run -- hook pre-merge --yes` — 4570 tests, clippy, fmt, doc sync. - Cross-platform nightly green on the cut-from tip `3817df079` (run 31133551751): full nextest matrix on linux/macOS/Windows, feature-powerset, all three release triples, nix-flake, minimal-versions, unused-deps, crate-build, link-check. - Changelog verified entry-by-entry against the diffs by an independent pass; every one of the 55 commits either maps to an entry or is a documented skip. - `main` advanced during the CI wait. #3762 ships in this release and now has a changelog entry; the other two commits that landed (#3758, #3749) touch only `.github/`. - Data-loss surface reviewed by four independent finders over the cumulative diff. One further finding — a pre-0.72 `approvals.toml` key containing `*` being reinterpreted as a wildcard on upgrade — was reviewed and accepted as out of scope for this release. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk 0.72.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.life-white.uk`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673)) - **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves) - **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting) - **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests) - **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677)) - **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747)) - **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675)) ### Fixed - **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766)) - **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740)) - **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734)) - **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting) - **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759)) - **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report) - **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600)) - **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762)) - **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727)) - **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719)) - **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692)) - **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656)) - **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731)) - **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687)) - **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688)) ### Documentation - **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709)) - **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706)) ### Internal - **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt See merge request: Harmonybrew/homebrew-core!16275
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
--formatflag, so the only way to learn that a non-interactive run would stop for approval was to run the operation and catchNotInteractive, or to pass--yesand approve whatever was there.{ "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"] }stateis what a caller branches on.stalestays a separate list rather than a fourthstate, because it co-occurs with all three — and those are the approvals--yeswould 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
statusverb, matchingwt config show,wt config state get,wt config state logs, andwt list.Closes #3698.
branch_outcomeon removal (#3700, partly)wt remove --format=jsonreported the branch as one boolean, collapsing five internal outcomes into two values:branch_deletedwasDeletedtrueDeferred— handed to a detached process, result never observedtrueNotAttempted— no branch, or--no-delete-branchfalseRetained— a sibling worktree has it checked outfalseRetained— the CAS refused; the ref moved under usfalseThe 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 samefalsethat means "you asked me not to". AndDeferredreportedtrueon intent.branch_outcomenames it instead:deleted,deferred,not_attempted,retained_unmerged,retained_checked_out,retained_raced,retained_failed. A caller that seesretained_racedknows 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
wtfail 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 indelete_branch_if_safeand runsgit branch -Dwith no integration check and no CAS. If an--expected-oidflag lands, it has to gate that path.Notes
branch_deletedis replaced, not supplemented, onwt remove --format=jsonand onwt 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-runkeepsbranch_deleted. A dry run predicts; it runs nothing to have an outcome. Different thing, different name, documented as such.retained_racedandretained_checked_outhave no deterministic CLI trigger. Both come from windows betweenwt'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, andcas_rejects_delete_when_branch_advancesinsrc/git/remove.rs, which drives the race with a stale snapshot). The integration tests cover the two reachable contrasts:retained_unmergedvia apre-removehook that commits, andnot_attemptedvia--no-delete-branch.print_jsonlives undersrc/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:
wt --config-set 'list.json-schema = 2' list --format=jsonpins the schema per invocation above every config layer, as doesWORKTRUNK_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.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 iscommands.jsonl.Testing
cargo run -- hook pre-merge --yesgreen: 4533 tests, clippy, fmt, doctests, rustdoc, docs sync.