Skip to content

Stop a failed fork PR switch from deleting the user's branch - #3984

Merged
max-sixty merged 5 commits into
mainfrom
fork-ref-no-rollback
Sep 2, 2026
Merged

max-sixty merged 5 commits into
mainfrom
fork-ref-no-rollback

Conversation

@max-sixty

@max-sixty max-sixty commented Sep 1, 2026 •

Copy link
Copy Markdown
Owner

The bug

wt switch pr:N / mr:N against a fork PR/MR took the CreationMethod::ForkRef
path, which built the branch, its tracking config, and its worktree as four separate
git calls and compensated for that decomposition on any error:

if let Err(e) = setup_result {
    // Cleanup: try to delete the branch if it was created
    let _ = repo.run_command(&["branch", "-D", "--", &branch]);
    return Err(e);
}

The rollback was only ever valid if the first call — git branch -- <b> FETCH_HEAD —
had succeeded. Its likeliest failure is git refusing a name that is already taken, and
in that case wt force-deleted a branch it had not created, discarding the result of
the deletion.

The reachability window

The branch name comes from the PR's head ref. Whether it is free is decided in
resolve_fork_ref, which runs during resolve_ref_shortcut_target — before the
pre-switch hooks and before plan_switch. The branch is created later, inside
execute_switch. Anything that claims the name in between selects the fork path and
then fails it: a pre-switch hook, or another session in another worktree of the same
repo.

When that happened, git branch -D dropped the branch, and with it any commits only
that branch held — the reflog goes too, so recovery is git fsck --lost-found. The
error printed immediately afterwards was:

✗ Failed to create local branch feature-fix from PR #42
  fatal: a branch named 'feature-fix' already exists

which reads as though nothing happened, and the obvious response — re-run — succeeds
the second time, erasing the evidence.

The fix: remove the rollback, don't guard it

The rollback existed only to compensate for a decomposition that shouldn't exist. The
sibling CreationMethod::Regular arm already builds a single
git worktree add -b <branch> -- <path> <ref> and has no rollback at all, because git
owns the atomicity. ForkRef now uses the same shape:

git worktree add -b <branch> -- <path> FETCH_HEAD

Git writes nothing when the name is taken (verified: the branch keeps its SHA and no
directory is left behind), so on the one failure that lands on a branch wt did not
create there is nothing to roll back. Guarding the delete with "only if we created it"
would have kept a destructive path alive to fix a case where the destructive path was
never needed.

This is not a claim that git worktree add -b is atomic in general — with the
destination path occupied it creates the branch and then dies on the path (verified on
git 2.55). That leftover is a stray branch at the PR head, which the Regular arm
already accepts, and it is not someone's work. It does carry the same follow-on cost as
a failed tracking write below — no config written, so the next wt switch pr:N takes
the prefixed-branch path or errors — and the docstring now says so in both places. The
old code rolled back cleanly in this one case, its own branch being the only thing it
could have deleted, but distinguishing it needs the very did-we-create-it reasoning that
produced the bug.

Tracking config (branch.<b>.remote / .merge / .pushRemote) now follows the
worktree instead of preceding it, and the ordering is load-bearing: writing tracking
first would rewrite the existing branch's upstream on exactly the collision above —
the same class of bug as the rollback. If a tracking write fails, the user is left with
a branch plus worktree at the PR head and incomplete config. Nothing is lost, and a
re-run is non-destructive: branch_tracks_ref reads the branch as tracking something
else, so the next wt switch pr:N takes the prefixed-branch path (GitHub/Gitea) or
reports BranchTracksDifferentRef (GitLab/Azure DevOps). That is strictly better than
deleting their branch. This matches the repo's stated rules: prefer failure over silent
loss
, no implicit destructive side effects, and one canonical path for worktree
creation.

branch.autoSetupMerge is inert on this path — git sets up tracking only where the
start point resolves under refs/heads/ or refs/remotes/, and FETCH_HEAD resolves
as itself, so no value of it (true, always, inherit, simple, false) writes
tracking config here. The -c branch.autoSetupMerge=simple the Regular arm needs
would be redundant.
git worktree add -b also has a known DWIM footgun where a bare remote-only start point
creates the remote's branch name instead of -b's; FETCH_HEAD is not a
remote-tracking ref, and the new test covers the case rather than assuming it.

Test

test_switch_pr_fork_failure_keeps_existing_branch pins the safety property: a
pre-existing branch survives a failed pr:N switch.
It uses the existing mock-gh
fork-PR fixture, plus a pre-switch hook that renames a never-pushed local branch onto
the PR's branch name — occupying it after the forge answered (which is what selects the
fork path) and before the worktree is created, standing in for the concurrent session
that does the same in real use. The test then asserts the switch failed and that
refs/heads/feature-fix still points at the unpushed commit.

Against the old code it fails at exactly that assertion, with the branch gone.

The same failure mapping as Regular

Creating the branch with git worktree add -b means the fork path now hits the one
failure the Regular arm already translates: git stores refs as file paths, so a fork
PR whose head ref is feature-fix can't create a branch in a repo that already has
feature-fix/nested, and git says fatal: cannot lock ref 'refs/heads/feature-fix'.
The arm routes that through detect_branch_namespace_conflict, exactly as Regular
does, so the user is told which local branch is in the way. It matters more here than
there: a PR's head ref isn't the user's to rename, so the only actionable move is to
rename the local branch, and the raw git text doesn't name it.

test_switch_pr_fork_namespace_conflict covers it — same mock-gh fixture, with
feature-fix/nested created before the switch. It asserts
BranchNamespaceConflict's own wording, collides with existing branch, rather than
just the conflicting branch name: git's raw cannot lock ref text names that branch
too, so the looser assertion passed against the unmapped error. Verified by reverting
the mapping — the test fails.

Validation

  • cargo run -- hook pre-merge --yes — 4,715 tests passed, 1 skipped; clippy, fmt,
    doc-sync, snapshot and lockfile checks green
  • No help text or after_long_help changed, so no doc regeneration was needed
  • The FAQ's "What can Worktrunk delete?" never listed this branch -D, so no doc update is
    needed — and its absence is more evidence the deletion was never intended behaviour

This was written by Claude Code on behalf of max-sixty

max-sixty and others added 2 commits September 1, 2026 13:25
The `CreationMethod::ForkRef` arm of `wt switch pr:N` / `mr:N` built the
branch, its tracking config, and its worktree as four separate git calls,
and compensated on any error by force-deleting the branch and discarding
the result: `let _ = repo.run_command(&["branch", "-D", "--", &branch])`.

Only the first call created anything, and its likeliest failure is git
refusing a name that is already taken — the branch is chosen from the
PR's head ref, and the check that it is free runs before the `pre-switch`
hooks, so anything that claims the name in between (a hook, a concurrent
session) lands there. In that case the rollback deleted a branch wt had
not created, taking any commits only it held, and the error printed
straight afterwards read "a branch named 'X' already exists" — as if
nothing had happened. Re-running erased the evidence.

Collapse the arm onto the shape the sibling `Regular` arm already uses: a
single `git worktree add -b <branch> -- <path> FETCH_HEAD`, which writes
nothing at all when the name is taken. That leaves nothing half-created,
so the rollback goes away rather than being guarded. Tracking config now
follows the worktree; if one of those writes fails the user is left with
a branch and worktree that aren't wired to the PR, which the next
`wt switch pr:N` picks up on its existing-branch path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9MfPQZNDkqUSGxeqgH5TQ
Review found the `setup_fork_branch` docstring overstating two things.

A failed `branch.<b>.remote` or `.merge` write does not leave a branch the
next `wt switch pr:N` adopts: `branch_tracks_ref` requires both keys to
match, so it reports the branch as tracking something else and the next
run takes the prefixed-branch path (GitHub/Gitea) or reports
`BranchTracksDifferentRef` (GitLab/Azure DevOps). Only a failed
`pushRemote` write leaves the state the docstring described. Nothing is
lost either way, which is the point, but say what actually happens.

And `git worktree add -b` is not atomic in general: with the destination
path occupied it prints "Preparing worktree", creates the branch, then
dies on the path — verified on git 2.55. It is atomic for exactly the
branch-name collision this fix is about, which is the claim the code
rests on. Also record why the creating call must come first: configuring
tracking ahead of it would rewrite the existing branch's upstream on that
same collision.

`branch.autoSetupMerge`'s rule is the start point resolving under
`refs/heads/` or `refs/remotes/`, not "a remote-tracking branch" —
`always` tracks a local branch start point too. `FETCH_HEAD` qualifies
under neither, so the conclusion stands.

The test reuses `set_github_remote_url` rather than hand-rolling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9MfPQZNDkqUSGxeqgH5TQ

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the fix; the reasoning holds and I independently confirmed the two load-bearing claims on git 2.55 rather than taking the PR body's word for them:

  • git worktree add -b <taken> -- <path> FETCH_HEAD against an occupied branch name fails at Preparing worktree, leaves the existing branch at its original SHA, creates no directory, and registers no worktree — so there is genuinely nothing for a rollback to undo on the one failure the old branch -D was mishandling.
  • No value of branch.autoSetupMerge (true, always, inherit, simple, false) writes branch.<b>.remote / .merge from a FETCH_HEAD start point, so omitting the -c the Regular arm needs is correct and the set_config calls below stay the only writers.

I also checked that CreationMethod::ForkRef is only ever selected when the branch does not exist (resolve_fork_ref returns Regular for both the tracking and prefixed-tracking hits), so -b can't collide with an adoption path, and that validate_worktree_creation deliberately skips branch-existence checks for ForkRef — which is what makes the new test exercise the real collision at git worktree add rather than a plan-time guard.

Not approving. This repo's reviewer guidance — .claude/skills/running-tend/references/review-pr.md, "Data-Loss Surface: Hold for Human Review" — routes any diff touching git branch -D to a human, and this one edits exactly that path in src/commands/worktree/switch.rs. It removes the destructive call rather than adding or widening one, and I'd have approved it on the merits otherwise; I'm following the rule rather than deciding the exception applies. The rule's next step is "request review from @max-sixty", which is moot here since you're the author.

One optional note inline about a small asymmetry the docstring's "exactly as the CreationMethod::Regular arm does" glosses over.

Comment thread src/commands/worktree/switch.rs Outdated
The ForkRef arm now creates its branch with the same `git worktree add -b`
as the Regular arm, so it hits the same namespace collision: git stores refs
as file paths, and a fork PR whose head ref is `feature` can't create a
branch in a repo that already has `feature/x`. Route the failure through
`detect_branch_namespace_conflict` as the Regular arm does, so the user sees
which local branch is in the way instead of git's "cannot lock ref" text.
`stderr.contains("feature-fix/nested")` passed against the unmapped error
too — git names the conflicting ref in its own "cannot lock ref" text — so
the test only discriminated through its negative assertion. Assert
`BranchNamespaceConflict`'s own wording as well. Verified by reverting the
mapping: the test fails.
The docstring called the branch left behind by an occupied destination path
"a stray branch at the PR head" and stopped there, while spelling out the
same follow-on cost two paragraphs later for a failed tracking write: no
config means the next `wt switch pr:N` takes the prefixed-branch path or
errors. Name it in both places, and note why the old code's clean rollback
of this one case isn't worth recovering.

The test's "four separate git calls" was five under its own fixture, which
has a fork URL and so writes three tracking keys. The count adds nothing.
@max-sixty
max-sixty merged commit ccb22a6 into main Sep 2, 2026
37 checks passed
@max-sixty
max-sixty deleted the fork-ref-no-rollback branch September 2, 2026 03:33
max-sixty added a commit that referenced this pull request Sep 2, 2026
`Repository::local_branches` scans `refs/heads/` once and caches the
result for the
lifetime of the `Repository`. Its docstring already scoped a staleness
contract to
`commit_sha` — "a snapshot at scan time", resolve through a
`RefSnapshot` instead — but
then said of the inventory itself:

> The inventory itself is used for branch listing and upstream-tracking
metadata, both of
> which are stable for the duration of a command.

That holds only for a command that runs no hooks. Hooks run *inside* a
command, so a
`pre-switch` or `pre-remove` hook that adds, deletes, or renames a
branch leaves the
cached membership stale, not just the SHAs.

## Where it shows up

The behaviour is intended — one scan per command is the point — but the
docstring said
membership was stable, which sends a reader looking for a bug when they
hit it. Two
consumers see it:

- `detect_branch_namespace_conflict` (`src/commands/worktree/switch.rs`)
reads the
inventory to name the branch blocking a `git worktree add -b`. A hook
that deletes
`feature/nested` and creates `feature` in the same command yields
"collides with
existing branch feature/nested" where "already exists" is the truth.
Cosmetic.
- `wt step prune` iterates `all_branches()` to choose what to prune, so
a branch created
by a hook mid-command isn't considered. Conservative, but a selection
difference rather
than a message one — which is why the note says membership rather than
"messages can be
  wrong".

## What this changes

The docstring, and nothing else. No behaviour change, no new test.

This came out of reviewing #3984, where the same staleness was flagged
against the
namespace-conflict mapping. A doc entry was considered and dropped: the
FAQ's questions
sit a level up from one cache's staleness, and there's no broader
question this is a
detail of.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@max-sixty max-sixty mentioned this pull request Sep 8, 2026
max-sixty added a commit that referenced this pull request Sep 8, 2026
Version bump to 0.77.0 plus the changelog section for this release.

**Bump level**: minor. `cargo semver-checks` reports 8 breaking library
changes (removed `DeprecationInfo::has_deprecated_patterns`,
`Repository::recent_commit_subjects`, `Repository::diff_stats_summary`,
`config::detect_deprecations`; arity changes on
`compute_migrated_content` and `Progress::record`). Pre-1.0, so a
breaking change takes a minor bump. `wt list --format=json` defaulting
to schema 2 is a user-facing break on its own.

**Validation**

- `cargo run -- hook pre-merge --yes` — 4831 tests passed, 1 skipped;
clippy, fmt, doc-sync, snapshot and lockfile checks green
- Nightly dispatched on the cut-from tip
(https://github.com/max-sixty/worktrunk/actions/runs/34272636821):
full-tests on linux, macos, windows and git-2.43 all green, alongside
release-target ×3, nix-flake, minimal-versions, crate-build, unused-deps
and link-check

**Changelog**

29 entries. Each was verified against its commit's diff by a separate
pass, which caught two uncovered fixes (#4033/#4034 uninstall
idempotency), one entry in the wrong section (#4018 is a fix, not an
improvement), and three unmentioned machine-visible breaks (`wt list`
JSON `marker`/`state`, `copy-ignored --dry-run` dropping
`files`/`bytes`, the hidden-column footer wording).

One correction to the previous release's notes: the `wt config update
--output` entry was filed under `## 0.76.0` by #4021, which landed after
v0.76.0 was tagged. It moves to 0.77.0, where it actually ships.

**Data-loss surface review**

Four independent finders swept the cumulative diff. The destructive core
is untouched — no changes in `src/commands/process.rs`,
`src/git/remove.rs`, `src/utils.rs`, or `src/commands/remove.rs`. This
release removes a loss path: #3984 deletes the fork-PR `git branch -D`
rollback that force-deleted a pre-existing branch, and its reflog, when
the branch name was already taken.

Three follow-ups, none blocking: `run_plugin_removal` reporting success
over a failed marketplace removal; the documented-not-fixed
`post-commit` window in `wt merge`; and `merge_args_into_command`'s
`args` precondition, now dead code since `drop_unsupported_keys` removes
the key regardless.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0134XuJoGCzwoZ21XXbWujdY

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request Sep 9, 2026
…posed (#4050)

An audit of the published v0.77.0 notes against the actual diffs found
eleven claims that overstated a bug's severity or did not match the
code. The goal was accuracy in both directions — a bug described as
worse than it was misleads as much as one described as milder.

**The fork-PR entry (#3984) was the worst offender.** It said a failed
switch cost you the branch "and any commits only it held", which implies
unrecoverable loss. Tested in scratch repos, recoverability depends on
the case:

- git refuses to delete a branch checked out in any worktree, so the
deletion only lands on a branch not currently checked out anywhere
- a branch committed on and then switched away from stays in that
worktree's HEAD reflog, and `git reflog` recovers it
- a branch whose creating worktree is gone is in no reflog, and only
`git fsck --unreachable` finds it, until gc prunes

Rather than pick one, the entry now claims only what holds in every case
— the ref was deleted — and names the race required to reach the
deletion at all. At resolve time the chosen branch name is always free
(`resolve_fork_ref` reuses a tracking branch, or falls to a prefixed
name), so something has to claim it in the window after the forge
answers.

**The OpenCode version floor was wrong in a way users would act on.**
The entry said "below 1.14.19 no longer loads it". 1.14.19 is where the
object export shape first loads, but 1.16 is the documented floor,
because that is where OpenCode began filtering events per plugin
instance — on 1.14.19–1.15.x the marker follows the wrong worktree.
Someone on 1.15 would have read the old number and concluded they were
fine.

**The rest**, each verified against the diff:

- `state` never carried `detached`; a detached worktree previously
reported `branch_worktree_mismatch`
- plain `wt config show` never panicked — only `--format=json` and
`--full`, plus the four `wt step` commands
- `wt step squash` has no `--branch` flag
- the `APPROVALS` section counts commands and links `wt config approvals
list`; it never names them
- the FAQ's new table lists three files, not five harnesses; the Claude
and Codex plugin installs write nothing themselves
- #4019's break differs per harness: Codex uninstall previously left the
plugin, Claude uninstall now also removes the marketplace
- the marker fix's hooks end in `|| true`, so the session kept going;
`wt` exited 1 and printed a git error each turn
- #4043's entry linked only the issue, unlike every other entry
- one entry attributed the empty-section cleanup to the wrong code path
(it belongs to `remove_section_key_in`)
- the `wt step copy-ignored` entry claimed a `--dry-run` plan "no longer
reports `files`/`bytes`", wrong in both directions: an ordinary dry-run
plan never reported them, and the `source_path == dest_path` early
return still does. Clause dropped (caught by review — the first version
of this description claimed a correction that had not landed)

## One code comment, no behavior change

Chasing the `copy-ignored` claim turned up a docstring that misdescribed
its own call sites. `insert_empty_counts` said "A `--dry-run` payload
takes none of them" and named "the two early returns below"; there are
three call sites, and the claim holds for two.

`same_worktree` returns before any dry-run branching and emits no
`dry_run` key, so it is not a plan — it is a no-op exit in either mode,
which is what the function's first line already describes. Taking the
zeroed counts unconditionally there is correct. The docstring now scopes
its claim to `outcome: "planned"`, counts the call sites right, and says
why `same_worktree` differs.

Worth noting the review read this asymmetry as a defect leaking counts
into dry-run output. It isn't one: `same_worktree` was never a dry-run
payload.

The published GitHub release notes for v0.77.0 have been updated to
match (they are generated from the tree at the tag, so they do not pick
this up automatically).

The v0.76.0 notes turned out to need nothing: the misfiled `--output`
entry was added to `CHANGELOG.md` after v0.76.0 was tagged, so it never
reached that release's published notes. Only the repo file was wrong,
and moving the entry into 0.77.0 fixed it.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0134XuJoGCzwoZ21XXbWujdY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
max-sixty pushed a commit that referenced this pull request Sep 15, 2026
## Problem

`git worktree add -b <branch>` writes the branch ref before it populates
the
worktree, and unwinds only what it registered. A failure in between — an
index
git can't write, leading directories it can't create — therefore ends
with the
branch present and nothing checked out on it:

```
$ git worktree add -b feat ../blocker/sub master
Preparing worktree (new branch 'feat')
fatal: could not create leading directories of '../blocker/sub/.git': Not a directory
$ git branch
  feat
* master
```

`wt switch --create` reported only `✗ Failed to create worktree for
<branch>
from base main`, so the leftover was invisible until the next
`wt switch --create <branch>` answered `Branch <branch> already exists`
— which
reads as a fresh name collision rather than as fallout from the first
failure.
That second error is what #4108 had to decode.

## Solution

On a failed `--create`, `failed_add_left_branch` asks whether the branch
is now
present with no worktree, and `WorktreeCreationFailed` gains a hint
naming it
with both recoveries:

```
✗ Failed to create worktree for feature/auth from base main
 ┃ fatal: could not create leading directories
↳ Branch feature/auth was created before the failure, with no worktree; to delete it, run git branch -d -- feature/auth; to use it, run wt switch feature/auth
```

The issue proposed deleting the ref or falling through to the no-create
path.
Neither is what this does, and the reasons are worth stating:

- **Deleting it** is the rollback #3984 removed. That rollback's
did-we-create-it reasoning was wrong once and force-deleted a branch
`wt` had
not created, taking commits only it held. `CLAUDE.md`'s data-safety rule
says
the same thing generally — no implicit destructive side effects, cleanup
is a
separate action the user chooses. A hint costs nothing and leaves the
choice
  where it belongs.
- **Falling through** would make `--create` silently adopt an existing
branch,
  which hides a genuine name collision — the case the error is for.

The detection is read-only and runs only on the error path. The worktree
lookup
goes through a fresh `Repository`, since `repo` cached its inventory
before the
add ran. `setup_fork_branch` deliberately keeps the old behavior: its
re-run is
`wt switch pr:N`, which adopts or prefixes the branch on its own terms,
so
naming the ref would point at a recovery that isn't the one to take.

## Testing

`test_switch_create_names_branch_left_by_failed_worktree_add` drives the
whole
path: a `worktree-path` template pointing under a regular file makes git
fail
exactly where it leaves the branch behind. It asserts the reproduction
(branch
present, no worktree registered on it) and then the hint. With the new
flag
forced off, it fails with the pre-change output — `✗ Failed to create
worktree
for stranded from base main` and nothing else — which is the reporter's
first
error verbatim.

`snapshot_worktree_creation_failed_leftover_branch` pins the rendered
hint.
`cargo test --lib`, the `switch` and `git_error_display` integration
suites, the
snapshot/output guards, and `cargo clippy --all-targets` are clean
locally; one
pre-existing unrelated failure
(`copy::tests::test_copy_dir_recursive_does_not_count_a_non_regular_file`,
which needs a FIFO the CI sandbox won't create) reproduces on an
unmodified tree.

Closes #4108 — automated triage

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.77.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

- **`wt list` sizes and aligns its columns to the terminal width**: one long branch name no longer sizes the Branch column for every row — it caps at 32 characters and elides, so a narrow terminal keeps its other columns instead of degenerating into a branch list. `Remote⇅` stops holding blank space open in a repo with no remote, and alignment now follows the value type. `--format=json` still carries the whole name. ([#3998](max-sixty/worktrunk#3998))

- **`wt list` names what it hid and marks detached worktrees**: the summary reads `hidden: Path, Commit` instead of counting columns, a detached worktree shows `⊘` rather than borrowing `⚑`, and a prunable row leaves its cells blank instead of showing loading dots that never resolve. ([#3998](max-sixty/worktrunk#3998))

- **`wt list --format=json` gains a `marker` field and stops reporting `detached` twice**: the branch marker set by `wt config state marker` is now readable without parsing it out of `symbols`, and `state` no longer reports a detached worktree as `branch_worktree_mismatch` — the sibling `detached` boolean carries it. ([#3998](max-sixty/worktrunk#3998))

- **`wt list --format=json` now defaults to schema 2**: callers get the envelope with repository metadata and orthogonal per-item facts without configuring `[list] json-schema`. `wt list statusline --format=json` follows the same key. Set `json-schema = 1` to retain the original bare-array format. (Breaking.) ([#4038](max-sixty/worktrunk#4038))

- **`wt config show` fails on a broken config**: it exits 1 on an unreadable or invalid config, an invalid `[list] columns`, or an invalid `approvals.toml`. Unknown keys and deprecations stay warnings and still exit 0. A new `APPROVALS` section counts project commands awaiting approval. (Breaking: it always exited 0 before.) ([#3999](max-sixty/worktrunk#3999))

- **Config migration output names its destination**: `wt config update --output <path>` writes the migration artifact to that file instead of applying it in place, and `--output=-` writes it to stdout. Output mode includes project config when run from a linked worktree. (Breaking: `--print` was removed.) ([#4021](max-sixty/worktrunk#4021))

- **Pi joins the agent integrations**: `wt config plugins pi install` writes an activity hook to `~/.omp/agent/hooks/pre/worktrunk.ts`, so Pi sessions show 🤖/💬 markers in `wt list` like Claude Code, Codex, OpenCode, and Gemini. `$PI_CONFIG_DIR`, `$PI_CODING_AGENT_DIR`, and named `$OMP_PROFILE`/`$PI_PROFILE` profiles are honored. [Docs](https://worktrunk.dev/claude-code/) ([#3594](max-sixty/worktrunk#3594), thanks @adity982, and @ortonomy for the request)

- **`wt config plugins codex install` installs the plugin too**: it previously registered the marketplace and left you to run `/plugins` in Codex yourself. Uninstall follows for both Codex and Claude Code, removing the plugin and then the marketplace, so the two commands are inverses. (Breaking: Codex uninstall previously left an installed plugin alone; Claude uninstall now also removes the marketplace.) ([#4019](max-sixty/worktrunk#4019))

- **`wt step copy-ignored` says whether it reflinked or copied**: the summary now reads `(reflinked, no extra disk)`, `(full copy)`, or a partial count, so the same `29.5 GB` line distinguishes a free copy from one that actually wrote the bytes. `--format=json` gains `reflinked` and `written`; a `--dry-run` plan no longer reports `files`/`bytes`. ([#4025](max-sixty/worktrunk#4025))

- **`wt config update` removes keys its destination cannot hold**: migrating `[select]` or `[commit-generation]` drops keys the new section has no field for, reporting each one. Otherwise the key landed at a path the user never typed and warned on every command, with no way to clear it. ([#3994](max-sixty/worktrunk#3994))

### Fixed

- **A failed `wt switch pr:<n>` against a fork no longer deletes a branch it didn't create**: the rollback force-deleted the branch on any setup error, including the one case where the branch wasn't its own — a `pre-switch` hook or a concurrent session claiming the name in the window after the forge answered. Branch and worktree are now created in one `git worktree add -b`, which writes nothing when the name is taken. ([#3984](max-sixty/worktrunk#3984))

- **Codex on Windows: the activity hooks no longer fail on every event**: each hook led with a bare `bash`, which `cmd.exe` resolves to the WSL launcher rather than Git Bash, so every event raised a `Hook failed` banner. Hooks now run `wt.sh` through a shim that finds Git Bash by path. ([#4008](max-sixty/worktrunk#4008), fixes [#4007](max-sixty/worktrunk#4007), thanks @McNultyyy for reporting and diagnosing)

- **Templates leave `branch` unset in a detached worktree**: aliases and hooks rendered `{{ branch }}` as the literal `HEAD`, which git resolves, so an unguarded template ran against the wrong ref — the reported case ended in `git push origin --delete HEAD`, which git refused. `base` and `target` follow. (Breaking: an unguarded `{{ branch }}` now errors.) ([#4010](max-sixty/worktrunk#4010), fixes [#4009](max-sixty/worktrunk#4009))

- **The OpenCode plugin loads under OpenCode 2**: OpenCode 2's loader dropped the old bare-function export silently, so activity markers stopped appearing. The plugin now exports one object carrying both the v2 `setup` and v1 `server` entry points, and spawns `wt` through `node:child_process` rather than the Bun shell. Re-run `wt config plugins opencode install` to pick it up; the one file covers OpenCode 2 and OpenCode 1.16 or later. ([#4018](max-sixty/worktrunk#4018), thanks @pragmaticivan for the request)

- **`wt step commit --branch` builds its prompt from the worktree being committed**: prompt-building git plumbing ran in the invoking worktree, so with nothing staged there the generated message described none of the changes it committed. `wt step relocate --commit` had the same bug, and `--dry-run`/`--show-prompt` now preview the worktree `--branch` names. ([#3996](max-sixty/worktrunk#3996))

- **LLM commit messages keep diffs for quoted paths**: a file whose name git quotes in the `diff --git` header — any non-ASCII name under the default `core.quotePath` — had its whole diff section dropped from the prompt once the diff exceeded the 100 KB budget. ([#4041](max-sixty/worktrunk#4041))

- **An unparsable user config is reported, not a panic**: `wt config show --format=json` and `--full`, plus `wt step prune`, `relocate`, `eval`, and `for-each`, panicked on a debug build and printed a bare `✗ Command failed` on a release one. They now report `✗ Failed to load config` with the parser's caret diagram. ([#4002](max-sixty/worktrunk#4002))

- **`wt switch --execute` names the directory the program actually starts in**: the `Executing (--execute) @ …` header rendered the worktree the background hooks run in, not the program's own directory — which differs under `--no-cd` or a switch from a subdirectory. The path is omitted when the shell already stands there. ([#4043](max-sixty/worktrunk#4043), fixes [#4042](max-sixty/worktrunk#4042), thanks @yajo for reporting)

- **`wt config plugins claude|codex uninstall` is safe to re-run**: removing an already-absent marketplace reported a failure, and a Claude uninstall that removed the plugin and then failed left the marketplace with no way to clear it. Both now finish a half-done uninstall and succeed when there is nothing left to remove. ([#4033](max-sixty/worktrunk#4033), [#4034](max-sixty/worktrunk#4034))

- **`wt config state marker` set and clear no-op outside a git repository**: they exited 1 with a git error, so an agent plugin running outside a repository printed one every turn (the hook's `|| true` kept the session going). ([#3981](max-sixty/worktrunk#3981), thanks @mahirhir)

- **`wt list` produces output in a bare repo with no worktrees**: it printed nothing at all and exited 0. It now reports `○ No worktrees` with a hint, `--branches` lists the branches, and `--format json` emits the envelope with an empty `items`. ([#3992](max-sixty/worktrunk#3992))

- **`wt list` warns when it can't parse the project config** rather than ignoring it silently; the listing still succeeds. ([#3993](max-sixty/worktrunk#3993))

- **Relayed command output no longer prints ahead of the delayed-stream progress line**, and delayed-stream output routes through anstream so color is stripped consistently when redirected. ([#4037](max-sixty/worktrunk#4037), [#3995](max-sixty/worktrunk#3995))

- **A hook filter that matches nothing names the unconfigured source**, rather than reporting no match without saying which source was empty. ([#3997](max-sixty/worktrunk#3997))

### Documentation

- **`wt hook` says that `post-*` hooks from the two sources run concurrently**, so dependent commands belong in one source; `wt merge` records that `post-commit` can't run when the merge removes the worktree it anchors on. ([#4020](max-sixty/worktrunk#4020), [#4026](max-sixty/worktrunk#4026))

- **`wt step` help pages describe what the commands actually do**: `wt step prune` says it removes br

See merge request: Harmonybrew/homebrew-core!18763
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants