github max-sixty/worktrunk v0.71.0
0.71.0

2 hours ago

Release Notes

Improved

  • Forge hosts are classified by exact DNS label: Host detection matched the bare substring, so github anywhere in a hostname selected the GitHub provider — evil-github.com and dev.azure.com.attacker.example included. Classification now requires github, gitlab, or gitea as a whole dot-separated label (Azure DevOps matches dev.azure.com / visualstudio.com as a domain suffix), and userinfo is split at the final @, so https://github.com@attacker.example/owner/repo resolves to attacker.example rather than GitHub. Ports and a trailing DNS root dot are normalized away first, so both transports classify identically.

    github.mycompany.com still 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, and mygithub.com all become unknown, alongside the lookalikes the change targets. What is lost is forge-derived detail: CI status in wt list --full, wt switch --prs, and repo.provider in wt list --format=json, which reads unknown. Setting forge.platform in .config/wt.toml restores all of it, and wt switch pr:{N} keeps working either way.

    Only one case announces itself — a single-label SSH alias of the github-personal form, 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, so wt switch 123 against a missing branch suggests wt switch pr:123 instead of offering both pr: and mr: forms. (Breaking: a remote whose host merely contains a forge name is no longer detected. Set forge.platform in 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 split WORKTRUNK_DIRECTIVE_CD_FILE / ..._EXEC_FILE pair 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 line To update the shell wrapper, run wt config shell install, and --execute disabled because the shell wrapper is out of date in place of running the payload. That makes a stale wrapper stricter than no wrapper at all, where --execute still 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 --execute until wt config shell install and 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 [24m rather 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 wt process 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 remove and worktree-only arguments), and the handler returns before main builds 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, and wt <Tab> from 22.2 ms to 12.8 ms. (#3664)

  • wt step prune prunes 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-wide git worktree prune they 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, --foreground ones, 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-finished mv) 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/* and refs/bisect/*, and any in-progress rebase or merge go with it — and git worktree repair can 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 lock exists for (removable media, a network mount, a dropped VPN). The lock is now honored whether or not the directory is present, and --force still does not override it, matching git 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 resolve HEAD. 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_removal pruned stale worktree metadata while planning the missing-directory fallback, so every "validate first" path quietly mutated — a refused wt remove of the default branch still pruned its entry, wt remove pruned 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 of wt step prune's queue instead of being skipped silently, and --dry-run plans 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 install previews and confirms the legacy files it reclaims: Install reclaims its own legacy wrapper paths — the fish conf.d/{cmd}.fish and stranded nushell autoload copies — but the deletion never appeared in anything the user saw before it happened. --dry-run returned 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 render Will remove <old> (deprecated; now using <new>), and the already-configured branch prompts before removing and preserves the file on decline. --yes still 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 HashMap and 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 --execute documents 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's comments preview 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-checks fails ten lints, all from the forge, shell, and removal consolidation — removed trait worktrunk::git::RefContext, enum worktrunk::git::CiPlatform (superseded by ForgeKind), function worktrunk::shell::detect_zsh_compinit, const DIRECTIVE_FILE_ENV_VAR (renamed RETIRED_DIRECTIVE_FILE_ENV_VAR), methods Repository::prune_worktrees, Cmd::directive_legacy_file, and GitRepoProvider::from_platform, and field RemoteRefInfo::ref_type; RemoteRefProvider::platform_label replaced by forge_kind; Repository::branches_for_completion gained a parameter; and BranchDeletionOutcome gained a RetainedCheckedOut variant. (#3662, #3650, #3664)

  • The integration suite stopped leaking a temp directory per test: isolated_test_cwd() held its TempDir in a LazyLock; 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, and git::recover::recover_from_path reads 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, and task profile-tests makes 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 FixtureRepo with 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 one mixed that 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 install

Install 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 install

Install prebuilt binaries via Homebrew

brew install worktrunk && wt config shell install

Download 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 install

Install via Winget (Windows)

winget install max-sixty.worktrunk && git-wt config shell install

Install via AUR (Arch Linux)

paru worktrunk-bin && wt config shell install

Don't miss a new worktrunk release

NewReleases is sending notifications on new releases.