Skip to content

fix(switch): name the branch a failed worktree add leaves behind - #4109

Merged
max-sixty merged 2 commits into
mainfrom
fix/issue-4108
Sep 15, 2026
Merged

max-sixty merged 2 commits into
mainfrom
fix/issue-4108

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

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 Stop a failed fork PR switch from deleting the user's branch #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

git worktree add -b writes the branch ref before it populates the
worktree and unwinds only what it registered, so a failure in between
ends with the branch present and nothing checked out on it. The first
failure said only "Failed to create worktree", so the next
`wt switch --create <branch>` reported "Branch … already exists" and
read as a fresh name collision rather than as fallout.

The --create path now checks, on failure, whether the branch is present
with no worktree, and names it in a hint alongside both recoveries. The
ref is never deleted: that rollback is the one #3984 removed.

Closes #4108
@worktrunk-bot worktrunk-bot added the automated-fix Automated CI fix label Sep 14, 2026
Both new early-exit lines were patch-coverage misses with no reachable
test: the `?` on a String write that cannot fail, and the guard clause
for a failed add that left no branch. Short-circuiting `&&` and a tail
`write!` say the same thing with nothing unreachable.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Data-safety surface, per Data-Loss Surface: Hold for Human Review in .claude/skills/running-tend/references/review-pr.md: this adds a copy-pasteable git branch -d -- <branch> to a user-facing hint in src/git/error.rs (the WorktreeCreationFailed arm). Nothing in wt runs it, and -d is the refusing variant — it declines an unmerged branch, so the branch created from an integrated base is the only thing it can remove. The rule still covers deletion commands a diff introduces for users to copy, so this needs a human sign-off rather than mine: requesting review from @max-sixty.

@max-sixty
max-sixty merged commit 07c9160 into main Sep 15, 2026
40 checks passed
@max-sixty
max-sixty deleted the fix/issue-4108 branch September 15, 2026 16:09
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.78.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

- **Hook scripts read renamed context keys**: the JSON piped to every hook drops `worktree`, `repo_root`, `main_worktree`, and `main_worktree_path` in favour of `worktree_path`, `repo_path`, `repo`, and `primary_worktree_path`. Config templates migrate on load, so hooks written with the old names keep working; scripts reading the JSON, `--execute` templates, and `--var` overrides need updating. (Breaking.) ([#4080](max-sixty/worktrunk#4080))

- **`wt config plugins pi` installs for Pi; oh-my-pi moves to `wt config plugins omp`**: `pi install` wrote an oh-my-pi hook, which Pi never loads; Pi users re-run it to get a Pi extension. Existing oh-my-pi hooks keep working, listed as outdated until `wt config plugins omp install`. (Breaking: scripts that run `wt config plugins pi` for oh-my-pi need `omp`.) [Docs](https://worktrunk.dev/claude-code/#oh-my-pi) ([#4135](max-sixty/worktrunk#4135), thanks @ashebanow)

- **Interactive prompts no longer open with a blank line**: `wt config shell install`, `wt config plugins claude install`, and the commit-message setup offer in `wt merge` began with one. ([#4059](max-sixty/worktrunk#4059))

- **`wt config show` gives a single `wt config shell install` hint**: an outdated wrapper, a fish wrapper at the deprecated `conf.d` path, and missing fish completions each printed their own hint. The zsh `compinit` snippet is now syntax-highlighted. ([#4059](max-sixty/worktrunk#4059))

- **`wt config update --output <path>` confirms the write**: it now prints `✓ Wrote user config migration @ ~/migrated.toml` where it printed nothing. `--output=-` stays silent. ([#4053](max-sixty/worktrunk#4053))

### Fixed

- **Removal no longer discards untracked files hidden by `status.showUntrackedFiles = no`**: under that setting, a worktree holding only untracked files looked clean, so `wt remove`, `wt merge`, and `wt step prune` removed it without `--force`. Safety checks now always count untracked files, and auto-staging warnings list every file they stage. ([#4111](max-sixty/worktrunk#4111), thanks @Duang777)

- **`wt remove` and `wt merge` check the right worktree under an inherited `GIT_DIR`**: run from a `!wt` git alias or a git hook, which pass `GIT_DIR` down, `wt remove` could delete a dirty worktree without `--force`, and `wt merge` could refuse a clean merge. Both now check the worktree they act on. ([#4082](max-sixty/worktrunk#4082), thanks @Duang777)

- **`wt merge` no longer deletes a worktree locked with `git worktree lock`**: the merge completes and prints `Worktree preserved (locked)`. Every Worktrunk removal refuses a locked worktree, even with `--force`; run `git worktree unlock <path>` first. ([#4073](max-sixty/worktrunk#4073), thanks @Duang777)

- **`wt config update` no longer overwrites a config edited while its prompt is open**: it wrote the pre-prompt snapshot over the newer file and reported success. It now fails with the path and leaves the newer file; re-running previews the current contents. ([#4127](max-sixty/worktrunk#4127), thanks @zach-hammad-vs for reporting)

- **`wt step prune` no longer deletes a branch created minutes ago from an older commit**: `--min-age` aged a branch without a worktree by its commit's date, so a new branch off a day-old commit was pruned on the next run. Age now comes from the branch's oldest reflog entry. ([#4077](max-sixty/worktrunk#4077))

- **A comment or blank line above an inline config section no longer breaks the config**: a save or migration that rewrote `commit = { … }` as `[commit]` put that line inside the brackets. `wt switch`, `wt merge`, `wt remove`, and several `wt step` commands then failed with `Failed to load config`; `wt list` warned and ran without the config. Saves also dropped unrecognized keys from inline sections. ([#4120](max-sixty/worktrunk#4120), thanks @zach-hammad-vs for reporting)

- **Config saves keep the trailing comment on a value they rewrite**: a save such as answering the commit-generation offer dropped the comment after each value it rewrote, including one after an inline section's closing brace. ([#4138](max-sixty/worktrunk#4138))

- **Ignored project-config keys warn from every worktree**: an unknown key in `.config/wt.toml`, or one that belongs in user config such as `[merge]`, warned only in the main worktree, so `wt merge` from a linked worktree ignored it silently. ([#4145](max-sixty/worktrunk#4145), thanks @ee-prog for reporting)

- **`wt merge` skips `post-commit` when it removes that hook's worktree**: before, the hook ran in the primary worktree when the removed one sat inside the repository, and otherwise didn't run. The merge now prints `▲ Skipped post-commit`; move work that must finish there to `pre-remove`. ([#4049](max-sixty/worktrunk#4049))

- **`wt switch` recovers from a removed worktree in a bare repository**: `wt switch` and the picker surfaced git's raw `fatal: Unable to read current working directory` instead, and the removed-directory message lacked `wt switch ^`. ([#4067](max-sixty/worktrunk#4067))

- **`wt config shell install fish` puts the wrapper where fish reads it under a custom `$XDG_CONFIG_HOME`**: the wrapper always went to `~/.config/fish/functions/`, so install reported success while `wt switch` changed no directory. On Windows, the fish completion moves from `%APPDATA%\fish\completions` to the wrapper's directory. ([#4107](max-sixty/worktrunk#4107))

- **`wt config shell install zsh` honours `$ZDOTDIR` only when it is absolute**: an empty or relative value resolved against the current directory, so install appended to a `.zshrc` under the current directory and reported success while the file zsh reads went untouched. A non-absolute value now falls back to `$HOME`. ([#4085](max-sixty/worktrunk#4085))

- **`wt config shell install` for Nushell no longer deletes a `wt.nu` under the current directory**: an empty or relative `$XDG_CONFIG_HOME` resolved against it, so install removed a `nushell/vendor/autoload/wt.nu` beneath it unread, as a stale wrapper. It now falls back to `~/.config`, which also fixes unread `tea` logins that let `wt switch pr:<n>` treat a Gitea host as GitHub. ([#4104](max-sixty/worktrunk#4104))

- **`wt switch --create` names the branch a failed worktree add leaves behind**: `git worktree add -b` writes the branch ref before populating the worktree, so a failure in between left the branch with no worktree, and the next attempt reported a name collision. The error now says how to delete or reuse it. ([#4109](max-sixty/worktrunk#4109), thanks @technicalpickles for reporting)

- **`wt config shell install` no longer tells an already-wrapped shell to restart**: reinstalling from a shell with the wrapper loaded, such as after a version bump, printed `↳ Restart shell to activate shell integration`. ([#4059](max-sixty/worktrunk#4059))

- **`wt config shell install` migrates a deprecated `conf.d` fish wrapper when `~/.config/fish/functions` is missing**: before, only `wt config shell install fish` migrated it. `wt config show` now reports that wrapper even when fish isn't on `PATH`. ([#4059](max-sixty/worktrunk#4059))

- **Plugin status and uninstall checks ask the agent CLIs**: `wt config show` could list an installed Claude Code plugin or Gemini CLI extension as not installed, and `wt config plugins claude|codex uninstall` could report success over a marketplace still configured. A repeat Claude uninstall now runs both removals instead of stopping at `Plugin not installed`. ([#4048](max-sixty/worktrunk#4048), [#4054](max-sixty/worktrunk#4054))

- **`wt config plugins opencode install` no longer writes the plugin into the current directory**: with `OPENCODE_CONFIG_DIR` set but empty, it wrote `plugins/worktrunk.ts` there, typically inside a repository, reporting success though OpenCode never saw it. An empty value now counts as unset. ([#4084](max-sixty/worktrunk#4084))

- **The OpenCode plugin clears its activity marker when OpenCode 1 disposes it**: only explicit session deletion cleared it, so a finished session kept its `💬` and `wt list` showed it as active until `wt config state marker clear` was run by hand. ([#4129](max-sixty/worktrunk#4129), thanks @drewdas for reporting)

- **`wt config update` migrates `[ci] platform` into a `[forge]` section that only sets `hostname`**: that config, common with GitHub Enterprise and self-hosted GitLab, kept the deprecated key with no deprecation warning. ([#4061](max-sixty/worktrunk#4061))

- **Deprecated keys in an inline `[projects]` entry now migrate**: `"example.com/org/repo" = { merge = { no-ff = true } }` skipped the migration the section form got, so the per-project setting was ignored. ([#4122](max-sixty/worktrunk#4122), thanks @zach-hammad-vs for reporting)

- **A deprecated config section the migration can't rewrite now warns**: `select = "not a table"`, or a `[select]` whose `[switch.picker]` destination is already set, was ignored without a message. It now reports `unknown field select (will be ignored)`. ([#4121](max-sixty/worktrunk#4121), thanks @zach-hammad-vs for reporting)

- **`wt config update` no longer changes what a template renders**: it could rename `{{ repo_root }}` inside a quoted s

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

Labels

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wt switch --create: detect/clean up orphan branch after a partial git worktree add failure

2 participants