Release Notes
Improved
-
Forge hosts are classified by exact DNS label: Host detection matched the bare substring, so
githubanywhere in a hostname selected the GitHub provider —evil-github.comanddev.azure.com.attacker.exampleincluded. Classification now requiresgithub,gitlab, orgiteaas a whole dot-separated label (Azure DevOps matchesdev.azure.com/visualstudio.comas a domain suffix), and userinfo is split at the final@, sohttps://github.com@attacker.example/owner/reporesolves toattacker.examplerather than GitHub. Ports and a trailing DNS root dot are normalized away first, so both transports classify identically.github.mycompany.comstill resolves, but a host that carries the forge name only as part of a label no longer does —gitlab-internal.company.com,github-mirror.example.com, andmygithub.comall become unknown, alongside the lookalikes the change targets. What is lost is forge-derived detail: CI status inwt list --full,wt switch --prs, andrepo.providerinwt list --format=json, which readsunknown. Settingforge.platformin.config/wt.tomlrestores all of it, andwt switch pr:{N}keeps working either way.Only one case announces itself — a single-label SSH alias of the
github-personalform, the shape used for multi-account setups:SSH host alias github-personal is not auto-detected as a forge; enable CI status and wt switch --prs with forge.platform = "github" @ .config/wt.toml. A self-hosted instance at a hyphenated hostname gets no such message; it simply stops reporting CI. Going the other way, a recognized Gitea or Azure DevOps remote now knows its own change-request vocabulary, sowt switch 123against a missing branch suggestswt switch pr:123instead of offering bothpr:andmr:forms. (Breaking: a remote whose host merely contains a forge name is no longer detected. Setforge.platformin project config.) (#3662) -
The retired single-file shell directive is no longer honored: Wrappers written before 0.37 passed one
WORKTRUNK_DIRECTIVE_FILE, mixing a trusted directory path with arbitrary shell for the parent to source. The splitWORKTRUNK_DIRECTIVE_CD_FILE/..._EXEC_FILEpair replaced it in 0.37, the fallback has warned since 0.54, and it is now gone. A shell still running the old wrapper fails closed on both halves:Cannot change directory — shell wrapper is out of date, with the repair lineTo update the shell wrapper, run wt config shell install, and--execute disabled because the shell wrapper is out of datein place of running the payload. That makes a stale wrapper stricter than no wrapper at all, where--executestill runs the command directly — deliberate, since a retired wrapper is the one case where worktrunk cannot tell what the parent will do with what it writes. Child processes can no longer inherit the retired capability. (Breaking: a live shell on a pre-0.37 wrapper stops changing directory and stops running--executeuntilwt config shell installand a shell restart; nushell needs the reinstall because its wrapper is a static file.) (#3662) -
Every hyperlink is underlined: The statusline underlined its PR reference but not the dev-server port, so nothing marked the port as clickable. Both now route through one helper that emits the OSC 8 sequence and the underline together, closing with
[24mrather than a full reset so a wrapping color or a dim survives the link. Text that is not a link stays plain: the picker cannot carry OSC 8 through skim, so it prints the same short text with no underline instead of underlining something unclickable, and on a terminal without OSC 8 support a dev-server URL still prints in full and stays copyable. (#3643, #3651) -
Shell completion answers a Tab press in roughly half the time: Completion forks a whole
wtprocess on every Tab press, with the user's finger still on the key, and it has nowhere to hide work — nothing paints progressively, no cache survives the process, and no shell caches a dynamic completion. It was doing every git read in sequence. The reads now overlap, the repository caches prewarm on this path,refs/remotes/is not scanned where a remote-only branch can never be offered (wt removeand worktree-only arguments), and the handler returns beforemainbuilds a rayon pool it never reaches.wt switch <Tab>goes from 45.7 ms to 27.4 ms on a fixture of 80 worktrees and 1400 remote-tracking refs; on worktrunk's own checkout, 47.9 ms to 29.0 ms, andwt <Tab>from 22.2 ms to 12.8 ms. (#3664) -
wt step pruneprunes stale metadata alongside its other removals: Stale-detached and not-yet-planned prunable candidates were held on the scan lock's write side because the repo-widegit worktree prunethey used to call raced sibling prunes. Each call now names its own entry, so both shapes join the read-side fan-out; the write side keeps only the candidates that run arbitrary commands or own the terminal — hook-bearing ones,--foregroundones, and the deferred current worktree. (#3655)
Fixed
-
Removing one worktree no longer unregisters another: Every stale-metadata cleanup in the removal chain ran a bare
git worktree prune, which takes no path filter — it walks every entry in.git/worktrees/and unregisters each one whose directory it cannot find at that instant. A worktree that is merely absent right now (an unmounted volume, a dropped network mount, a half-finishedmv) is indistinguishable from a deleted one, so removing worktree A also unregistered bystander B. B's commits and files survive, but its admin directory does not: the index,ORIG_HEAD, the per-worktree reflog,refs/worktree/*andrefs/bisect/*, and any in-progress rebase or merge go with it — andgit worktree repaircan relink an admin directory but cannot recreate one. Each cleanup now names the entry it means to remove, so a removal's blast radius equals its intent. (#3650) -
A locked worktree whose directory is missing is no longer removed: The missing-directory fallback was tested before the lock guard, so a worktree that was both locked and currently absent skipped the lock entirely — its registration was pruned and it fell through to branch deletion, which is precisely the case
git worktree lockexists for (removable media, a network mount, a dropped VPN). The lock is now honored whether or not the directory is present, and--forcestill does not override it, matchinggit worktree remove. Fixes #3645. (#3647) -
A branch that gains a checkout just before deletion is retained: Branch deletion read worktree topology from cache, so a worktree registered after that read — by a hook, or a concurrent
wt switch— was invisible, and the ref was deleted out from under it, leaving that worktree unable to resolveHEAD. A fresh topology read now sits immediately before the compare-and-swap delete, and a branch found checked out (live or locked) is kept and reported. Git exposes no transaction spanning worktree registration and ref updates, so the window cannot be closed entirely; it is now as narrow as two adjacent commands allow, and either one failing leaves the branch intact. (#3662) -
Planning a removal no longer prunes metadata as a side effect:
prepare_worktree_removalpruned stale worktree metadata while planning the missing-directory fallback, so every "validate first" path quietly mutated — a refusedwt removeof the default branch still pruned its entry,wt removepruned before its approval prompt, and the picker pruned on skim's event loop during row validation. Planning is now a pure read, and the prune happens at execution, ahead of the branch deletion that may still be declined. Two related contracts move with it: a failing metadata prune surfaces as a removal failure and abandons the rest ofwt step prune's queue instead of being skipped silently, and--dry-runplans stale entries through the same gate as every other candidate, so a dirty prunable-but-present entry is correctly left out of the preview. (#3658) -
wt config shell installpreviews and confirms the legacy files it reclaims: Install reclaims its own legacy wrapper paths — the fishconf.d/{cmd}.fishand stranded nushell autoload copies — but the deletion never appeared in anything the user saw before it happened.--dry-runreturned an empty cleanup list, the?preview showed only the files to add, and when everything was already configured the cleanup ran and returned without prompting at all. Both previews now renderWill remove <old> (deprecated; now using <new>), and the already-configured branch prompts before removing and preserves the file on decline.--yesstill skips the prompt, so no non-interactive flow changes. Fixes #3644. (#3648) -
Completion offers remote branches in a stable order: They were grouped through a
HashMapand sorted by timestamp with a stable sort, so on tied timestamps the order was whatever that process's hashing produced — three consecutive completions could print three different orders. Ties now break on name. (#3664) -
The Codex plugin clears its activity marker when the session ends: The marker returned to idle only at turn end, so one set by a session's last turn outlived the session. Codex now exposes
SessionEnd, and a main-session exit hook clears the marker there, within Codex's three-second maximum hook timeout. (#3660)
Documentation
wt switch --executedocuments what{{ base }}resolves to without--create: The help text said{{ base }}and{{ base_worktree_path }}require--create; they describe the source either way — the selected base with--create, or the invoking worktree when switching to an existing one. The picker'scommentspreview tab was likewise documented as--prs-only when it loads for any row whose branch has a PR/MR. Both are help-text corrections; neither behavior changed. (#3663)
Internal
-
Library API rework (Breaking library API):
cargo-semver-checksfails ten lints, all from the forge, shell, and removal consolidation — removed traitworktrunk::git::RefContext, enumworktrunk::git::CiPlatform(superseded byForgeKind), functionworktrunk::shell::detect_zsh_compinit, constDIRECTIVE_FILE_ENV_VAR(renamedRETIRED_DIRECTIVE_FILE_ENV_VAR), methodsRepository::prune_worktrees,Cmd::directive_legacy_file, andGitRepoProvider::from_platform, and fieldRemoteRefInfo::ref_type;RemoteRefProvider::platform_labelreplaced byforge_kind;Repository::branches_for_completiongained a parameter; andBranchDeletionOutcomegained aRetainedCheckedOutvariant. (#3662, #3650, #3664) -
The integration suite stopped leaking a temp directory per test:
isolated_test_cwd()held itsTempDirin aLazyLock; statics do not run destructors at process exit, and nextest runs one process per test, so every test leaked an empty directory into the system temp root — 704 per integration-suite run, and one machine had accumulated 454,907 entries. Stale entries are cheap to ignore but expensive to enumerate, andgit::recover::recover_from_pathreads every ancestor of a deleted CWD: one recovery test took 14.2 s against that temp root versus 0.27 s against an empty one. One fixed directory replaces the leak, fixtures root under$TMPDIR/wt/rather than the shared temp root, andtask profile-testsmakes the measurement repeatable. (#3604, #3666) -
The integration suite converged on distinct contracts: Two tranches removed duplicated and false-confidence coverage — overlapping list-layout suites, command-by-shell wrapper cross-products, and setup variants that never reached the behavior they named — and cut the switch picker from 42 PTYs to 19. Snapshots go from 1,259 to 1,131, a warm all-feature run from 84.7 s to roughly 73 s, and comparable production-line coverage is essentially unchanged at 97.3% to 97.2%. (#3657, #3663)
-
Benchmark fixtures and harness are shared: Each benchmark target had reimplemented temporary-repository ownership, linked-worktree paths, subprocess isolation, and warm/cold loops; a shared
FixtureRepowith canonical command and cache helpers replaces that, leaving each scenario's workload and destructive lifecycle explicit. The completion benchmark now fails when its measured subprocess fails instead of silently timing it, and its two fixture ids collapse into onemixedthat reaches the completer's 100-candidate threshold. (#3669, #3664)
Install worktrunk 0.71.0
Install prebuilt binaries via shell script
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.71.0/worktrunk-installer.sh | sh && wt config shell installInstall prebuilt binaries via powershell script
powershell -ExecutionPolicy Bypass -c "irm https://github.com/max-sixty/worktrunk/releases/download/v0.71.0/worktrunk-installer.ps1 | iex"; git-wt config shell installInstall prebuilt binaries via Homebrew
brew install worktrunk && wt config shell installDownload worktrunk 0.71.0
| File | Platform | Checksum |
|---|---|---|
| worktrunk-aarch64-apple-darwin.tar.xz | Apple Silicon macOS | checksum |
| worktrunk-x86_64-apple-darwin.tar.xz | Intel macOS | checksum |
| worktrunk-x86_64-pc-windows-msvc.zip | x64 Windows | checksum |
| worktrunk-aarch64-unknown-linux-musl.tar.xz | ARM64 MUSL Linux | checksum |
| worktrunk-x86_64-unknown-linux-musl.tar.xz | x64 MUSL Linux | checksum |
Install via Cargo
cargo install worktrunk && wt config shell installInstall via Winget (Windows)
winget install max-sixty.worktrunk && git-wt config shell installInstall via AUR (Arch Linux)
paru worktrunk-bin && wt config shell install