Repository navigation
Stop a failed fork PR switch from deleting the user's branch - #3984
Conversation
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
left a comment
There was a problem hiding this comment.
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_HEADagainst an occupied branch name fails atPreparing 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 oldbranch -Dwas mishandling.- No value of
branch.autoSetupMerge(true,always,inherit,simple,false) writesbranch.<b>.remote/.mergefrom aFETCH_HEADstart point, so omitting the-ctheRegulararm needs is correct and theset_configcalls 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.
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.
`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>
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>
…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>
## 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>
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
The bug
wt switch pr:N/mr:Nagainst a fork PR/MR took theCreationMethod::ForkRefpath, which built the branch, its tracking config, and its worktree as four separate
git calls and compensated for that decomposition on any error:
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
wtforce-deleted a branch it had not created, discarding the result ofthe 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 duringresolve_ref_shortcut_target— before thepre-switchhooks and beforeplan_switch. The branch is created later, insideexecute_switch. Anything that claims the name in between selects the fork path andthen fails it: a
pre-switchhook, or another session in another worktree of the samerepo.
When that happened,
git branch -Ddropped the branch, and with it any commits onlythat branch held — the reflog goes too, so recovery is
git fsck --lost-found. Theerror printed immediately afterwards was:
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::Regulararm already builds a singlegit worktree add -b <branch> -- <path> <ref>and has no rollback at all, because gitowns the atomicity.
ForkRefnow uses the same shape: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
wtdid notcreate 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 -bis atomic in general — with thedestination 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
Regulararmalready 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:Ntakesthe 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 theworktree 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_refreads the branch as tracking somethingelse, so the next
wt switch pr:Ntakes the prefixed-branch path (GitHub/Gitea) orreports
BranchTracksDifferentRef(GitLab/Azure DevOps). That is strictly better thandeleting 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.autoSetupMergeis inert on this path — git sets up tracking only where thestart point resolves under
refs/heads/orrefs/remotes/, andFETCH_HEADresolvesas itself, so no value of it (
true,always,inherit,simple,false) writestracking config here. The
-c branch.autoSetupMerge=simpletheRegulararm needswould be redundant.
git worktree add -balso has a known DWIM footgun where a bare remote-only start pointcreates the remote's branch name instead of
-b's;FETCH_HEADis not aremote-tracking ref, and the new test covers the case rather than assuming it.
Test
test_switch_pr_fork_failure_keeps_existing_branchpins the safety property: apre-existing branch survives a failed
pr:Nswitch. It uses the existing mock-ghfork-PR fixture, plus a
pre-switchhook that renames a never-pushed local branch ontothe 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-fixstill points at the unpushed commit.Against the old code it fails at exactly that assertion, with the branch gone.
The same failure mapping as
RegularCreating the branch with
git worktree add -bmeans the fork path now hits the onefailure the
Regulararm already translates: git stores refs as file paths, so a forkPR whose head ref is
feature-fixcan't create a branch in a repo that already hasfeature-fix/nested, and git saysfatal: cannot lock ref 'refs/heads/feature-fix'.The arm routes that through
detect_branch_namespace_conflict, exactly asRegulardoes, 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_conflictcovers it — same mock-ghfixture, withfeature-fix/nestedcreated before the switch. It assertsBranchNamespaceConflict's own wording,collides with existing branch, rather thanjust the conflicting branch name: git's raw
cannot lock reftext names that branchtoo, 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
after_long_helpchanged, so no doc regeneration was neededbranch -D, so no doc update isneeded — and its absence is more evidence the deletion was never intended behaviour