Release Notes
Improved
-
wt mergeandwt step pushleave the target worktree's uncommitted changes in place: Both commands used to move a dirty target worktree's changes aside with an autostash (git stash push -u) and restore them after the push, because the fast-forward'sreceive.denyCurrentBranch=updateInsteadrefuses any dirty worktree at all. That design enteredrefs/stash— a repo-global namespace any process can mutate — and restored staged changes as unstaged. Both strategies now advance the target through one path: a compare-and-swapupdate-ref, thenupdate-index --refreshplusread-tree -m -uin the target worktree (git's documented lenientpush-to-checkoutpolicy), with a CAS rollback if the sync can't apply, so branch and worktree move together or not at all. Uncommitted changes at paths the push doesn't touch never move — unstaged edits stay unstaged, staged entries stay staged, untracked files stay put, andrefs/stashis never involved. The upfront check that names a conflicting file also reads--porcelain -uallnow, so an untracked file inside an untracked directory is caught and named rather than slipping past a collapseddir/entry. (Breaking: the fast-forward path no longer runs agit push, sopre-pushand the receive-side hooks —pre-receive,update,post-receive,post-update, and anypush-to-checkout— no longer fire. Agit mergerun in the target wouldn't run them either, which is the line the module spec draws, and the new sync reimplements the lenient policypush-to-checkoutdocuments rather than invoking the hook. Separately, a sync that can't apply now fails the command with the ref rolled back, where--no-ffpreviously warned and left the worktree stale behind its own branch.) (#3703, #3684, #3693, thanks @gubasso for reporting the stash race that started this) -
A host carrying a forge's name anywhere resolves to that forge again: 0.71.0 required
github,gitlab, orgiteaas a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS, sogithub.attacker.examplecarried the exact label and classified fine, while the rule shut out the self-hoster with a hyphenated name.github-enterprise.acme.com,mygithub.com,gitlab-internal.company.com, and thegithub-personalSSH alias classify again, so CI status,wt switch --prs, andrepo.providerwork with no config. The cost is a host that merely sounds like a forge getting a forge CLI run at it, which surfaces as that CLI's error rather than as silence. Azure DevOps keeps suffix matching ondev.azure.com/visualstudio.com— those are service domains rather than a brand in the host — and userinfo still resolves to the network host, sohttps://github.com@attacker.example/…isattacker.example. Thegithub-personaldiagnostic 0.71.0 added is gone with the rule it explained. (#3673) -
One
[projects."…"]entry can cover every repository on a host, and can set the forge: A key containing*matches any run of characters,/included, so[projects."git.company.example/*"]covers every repository on that host, nested groups and all. Every matching entry applies, least- to most-specific, so a narrower key wins where two set the same field and leaves the rest alone. The table also gainedforge.platformandforge.hostname, which previously lived only in each repository's committed.config/wt.toml— so a self-hosted host carrying none of the three brand names needs one user-config entry rather than a block in every repo. A repository's own[forge]still wins field by field.approved-commandsmatches by the same rules, but only a hand-written key is ever a pattern:wt config approvals addrecords under the exact project identifier, andclearempties only the exact entry. Docs (#3701, thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves) -
wt list --format=jsonschema 2 has a published JSON Schema: The contract is at worktrunk.dev/schema/list-v2.json, andwt list --print-schemaprints the same document. Four fields that were bare strings —checks.status,display.state,default_branch.integration.reason, andworktree.operation— are now enumerated vocabularies, so a new variant is a compile error rather than a value silently missing from the published schema. The emitted JSON is unchanged. (#3747) -
Approval state and branch-removal outcomes are machine-readable:
wt config approvals list --format=jsonreports which of three states a non-interactive run would hit —no_commands,approved,approval_required— so an orchestrator can learn that a run would stop for approval without running the operation or passing--yes. A separatestalelist carries approvals whose command template has changed since, which--yeswould otherwise silently re-approve; it co-occurs with all three states, so it isn't a fourth. Separately, removal'sbranch_deletedboolean collapsed five internal outcomes into two values, reporting a lost CAS race and an explicit--no-delete-branchas the samefalse, and a deferred deletion astrueon intent alone.branch_outcomenames them:deleted,deferred,not_attempted,retained_unmerged,retained_checked_out,retained_raced,retained_failed. (Breaking:branch_deletedis replaced, not supplemented, onwt remove --format=jsonandwt step prune --format=json's live path.) (#3710, thanks @NathanaelRea for the requests) -
A detached worktree is named by its commit, not
-: The Branch cell hardcoded-for a worktree with no branch, which reads as missing data rather than as a state — and it was the odd one out, since the skeleton row, the statusline, and the picker all reached for(detached). The cell now carries the row's abbreviated HEAD in dim yellow, and the picker filters and the statusline name the worktree the same way. Detached worktrees aren't exotic here: Codex creates one per session. (#3675) -
Every commit hash worktrunk prints follows
core.abbrev: Thewt listtable slicedhead[..8]while--format=json'sshort_shacarried git's%h, so one commit read1b9f1d96in the table and1b9f1d9in JSON.wt switch --prs'slogtab andwt config state's CI cache table sliced to 8 too. All of them now ask git how wide it abbreviates in this repo, cached once per process, socore.abbrev = 12no longer truncates mid-hash and the default 7 stops reserving a column nothing fills. Where the commit-details batch fails, the Commit cell is now empty rather than a slice of a SHA git refused. (#3676, #3677)
Fixed
-
wt mergeandwt step pushrefuse a target worktree parked mid-operation: The target sync is a two-tree merge, which refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict has already been staged. A target paused between steps could therefore have the push range written into it, and the user's--continuewould commit the synced tree as the step's result. The old fast-forward path got this check for free fromreceive.denyCurrentBranch=updateInstead, which refused any unclean target outright; both strategies now ask directly, and the refusal names the worktree holding the operation. (#3759) -
An alias or hook wrapping
wt switchorwt removekeeps your subdirectory:wt switchandwt removeland you in the destination's matching subdirectory, but the resolution read the user's position from thewtprocess's own cwd — which answers "where is the user standing?" only for a top-level invocation. Alias and hook bodies run with the worktree root as their working directory, so with[aliases] finish = "wt remove -y",wt finishfromfeature/apps/gatewaydropped you at the primary worktree root. The shell's directory now travels with the CD directive file, to exactly the children allowed to move the user's shell, and each layer forwards it so nesting composes. Nothing about the working directory of alias or hook bodies changes;{{ cwd }}is still the worktree root. Fixes #3723. (#3724, thanks @vivienm for reporting) -
An
rmalias no longer breaks the shell wrappers' cleanup: zsh, bash, and nushell all expand aliases when the wrapper's function is parsed, so a user'salias rm='rm -v'defined before the wrapper was installed baked itself into the cleanup. On zsh and bash that printed two lines ofremoved '/tmp/…'noise perwtinvocation. On nushell it was worse: nushell raises on a non-zero external exit, so anrmalias that failed aborted the wrapper before it returned — the command's stdout was silently discarded and three temp files leaked. All three now bypass alias resolution (command rm, and^rmon nushell, whose builtin is kept on Windows where there is no externalrm), with the nushell cleanup wrapped intryso it can never abort the wrapper again. (#3714, thanks @Ar4l), (#3732) -
A failing
--executebody no longer strands the nushell wrapper's temp files: The wrapper's--executecall wasn't wrapped intry, so a body exiting non-zero unwound the whole function — cleanup never ran, all threemktempfiles leaked, and the stdout the function still owed was discarded. The existing test passed because the unwind happened to carry the exit code out to the shell, not because the wrapper handled it. (#3734) -
Piped output no longer panics when the consumer stops reading:
wt list | head -3andwt list statusline | head -1exited 101 withfailed printing to stdout: Broken pipe, and so didwt config state get --format=json | head -3. std'sprint!/println!panic on aBrokenPipewhile anstream's drop it, so which behavior a surface got was decided by which macro its file happened to import —wt config show --format=json | head -3had always exited cleanly. Every stdout surface now exits cleanly instead: the thirty-six--format=jsoncall sites through oneprint_json, the human-read ones — thewt listtable,--version,--help-md,--help-description, andwt config update --print— through anstream, and the two whose escapes are data rather than presentation —wt list statuslineand the--help-pagedocument — through a verbatim printer that drops aBrokenPipewithout stripping their ANSI. (#3746, #3766) -
wt listno longer colors a pipe:--helpdocumentsNO_COLORandCLICOLOR_FORCE("Force colored output even when not a TTY"), which only means something if color is off when stdout isn't a terminal. The table wrote its escapes through std's macros, so neither variable reached it and a pipe got ANSI unconditionally. Piped output is now plain by default, withCLICOLOR_FORCE=1to keep color.NO_COLORreaches the buffered table — the piped case, plus--no-progressiveon a terminal — while the default progressive terminal rendering bakes each row's escapes in and is unchanged. (#3766) -
--print-schemaand the doc-generation help flags name the right command:--print-schema,--help-page, and--help-descriptioneach scannedargvfor the subcommand, recognizing the binary by a/wtsuffix or atarget/path component. Neither matcheswt.exeunder a backslash path, so on every Windows install all three read the binary's own path as the command:--print-schemaexited 2, and the other two exited 0 with empty output. The scan also took the value of a preceding global as the command, so on any platformwt -C <path> list --print-schemaansweredNo JSON schema for '<path>'. All three now take the name clap already parsed, and--print-schemaprints through the sameprint_jsonas the--format=jsonsurfaces, so a closed consumer no longer panics. (#3762) -
A CI check that hasn't finished no longer reads as passed: Each forge's status parser was missing values its API documents, and in every case a non-terminal state read as terminal — so a row settled on a verdict before CI had one. On GitHub, a check parked on a deployment-protection rule or a required reviewer (
WAITING,REQUESTED) didn't count as running, sowt list --fullshowed a green cell for a PR blocked on an approval gate that had run nothing;EXPECTEDwas matched against the wrong field, and a run that failed to start (STARTUP_FAILURE) counted neither way. On GitLab,cancelingandwaiting_for_callbackrendered the same empty cell as a branch with no pipeline at all. On Azure DevOps, an in-flightcancellingreported red before anything had finished,postponedrendered empty, and a terminalpartiallySucceededbuild rendered as no CI. GitHub's, GitLab's, and Azure DevOps's documented enums are now fully covered. (#3741, #3740) -
A Gitea API error is reported as one, not as a parse failure:
tea apicopies the response body to stdout and exits 0 whatever the HTTP status, so both Gitea call sites guessed the outcome from the body's shape. Gitea blanks a 500's message for non-admin tokens, so that body fell through to the data path —wt listloggedFailed to parse tea api pulls JSONonce per branch, andwt switch pr:<n>failed withThis may indicate a Gitea API change, both blaming an API change for an API error.tea api --includewrites the status line to stderr, so the status is available after all: a body Gitea never wrote (a reverse proxy's HTML page) is now an error rather than a resource, and retriability is the status code rather than a search for429in Gitea's prose. A blanked 500 shows the error indicator where it used to paint the same blank cell as a healthy branch with no CI. (#3713, #3600) -
wtinstalled under a dotted name generates shell integration for that name:binary_namederived the command fromargv[0]withfile_stem, which cuts at the last dot wherever it is, on every platform — sowt config shell init bashunderwt.oldemitted a wrapper forwt, a command the user may not have. It now strips only the executable suffix, case-insensitively. A non-UTF8argv[0]is rejected withInvalid shell integration command namerather than quietly generating integration for a different command. (#3719) -
A multibyte shell name no longer panics:
extract_filename_from_pathcompared a name's last four bytes against.exeby slicing atlen() - 4with no char-boundary check, soSHELL=/bin/日本語 wt config showpanicked. On macOS every process name on the machine goes through this function during shell detection, so one CJK-named process crashed any command that detects the shell. (#3727) -
Concurrent
wt step pruneremovals no longer race the worktree registry:git worktree removeenumerates every entry under.git/worktrees/and reads each sibling'scommondirwhile resolving its target, so two overlapping removals could have one read a sibling in the window after another worker began tearing it down. Registry-mutating removals now serialize behind a second lock; a plain branch deletion's checkout probe only reads the registry, so those still run concurrently. (#3692) -
The Claude plugin's worktree-remove hook resolves against the worktree path: The hook anchored at
CLAUDE_PROJECT_DIR, which can only ever name one directory — but theclaude agentsview spans every repository you have run a background session in, and is typically launched from a parent that merely contains them. Both the anchor and its.fallback then landed outside any repository,wt removedied withnot a git repository, and the session became undeletable. The hook now passes the worktree path to-C: a linked worktree's.gitfile points at its owning repository, so discovery resolves wherever the worktree lives on disk. The hook's own guard narrowed in the same release: it skipped only a path that was gone, so a skeleton directory left by an interrupted create or remove passed the check and reachedwt remove, which then read the path as a branch name and exited non-zero. Claude Code reads that as a failed removal and keeps the session row, and since prune ignores a directory that was never a worktree, nothing healed it. The guard now requires a.gitentry. (#3754, thanks @judewang), (#3767, thanks @judewang for reporting) -
The
wt switchfirst-run offer previews the legacy files it removes: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper — a fishconf.d/wt.fishsuperseded byfunctions/wt.fish, or a stranded nushell autoload copy — which the confirmation prompt never named, reporting it only after the fact. The offer's preview now lists the same removalswt config shell installpreviews. What gets removed is unchanged; only what the prompt discloses. (#3656) -
wt list --branchescounts a local branch containing/as local: The summary tally classified branch-only rows bybranch.contains('/'), so a localfeature/logincounted under "N remote branches". It now reads the same structural scope the gutter sigil already did. (#3687) -
wt step relocate's human summary counts template-error branches as skipped:--format=jsonfolded them into itsskippedset; the human tally counted only validation and executor skips, so it undercounted by the number of branches whoseworktree-pathtemplate failed to expand. (#3688) -
wt config create --projectwrites a resolvable link: The comment it writes into.config/wt.toml— and thatwt config create --helpprints — carried a raw](@/config.md#…)Zola target. The link-conversion regex stopped at the first], which here closed a nested`[projects]`code span. Both the config transform and the skill mirrors now share one rule for brackets in link text, and an unconvertible link fails the sync test naming the line rather than reaching a user's config. (#3731)
Documentation
-
SignPath attribution appears with the artifacts it describes: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires, rather than only on the policy page. The policy page leaves the docs navigation in the same change — unlisted, still published at
/code-signing/. (#3709) -
wt step copy-ignored's--require-includeexample renders as a terminal block: It was the onlyconsoleblock in the command's long help missing the$prefix, so the web docs rendered it as a plain code fence. (#3706)
Internal
-
Library API rework (Breaking library API):
cargo-semver-checksfails five lints — structworktrunk::git::LegacyForgeAliasand methodRepository::legacy_forge_aliasremoved with the forge-classification revert,Repository::forge_platform_overrideremoved in favor of one shared resolver,worktrunk::git::stage_worktree_removalgained two parameters,UserProjectOverridesgained aforgefield, and thereal-repo-benchesfeature was removed. (#3673, #3694, #3701, #3721) -
One function owns the pre-removal gate: The dirty-worktree gate, the fsmonitor stop, and the rename into trash were assembled inline by both removal paths, so the default background path never read the module spec written for them.
stage_worktree_removalnow owns all three steps and both paths call it. No behavior change; the gate is now pinned on the default path as well as--foreground. (#3694) -
-vvtraces report a normal run's fork count:prewarm_atfast-pathed on a cache key that-vv's own logging setup populated before prewarm ran, so-vvskipped the git-config and user-config preloads entirely and every trace overstated a normal run's config forks. Each prewarm thread now gates on the cache it populates. Repositories usingextensions.worktreeConfigalso gained the preload, which they had never received, saving a fork per invocation. (#3705) -
The test mock stub is the
wtbinary itself:cargo test --test integrationneither built nor rebuilt the separatemock-stubhelper package, so a fresh tree panicked and a warm one could run a stale stub.wtis now linked under each mock's name and dispatches to the ported playback, which cargo rebuilds whenever the integration tests build. The helper package, its dummy test, thedefault-membersentry, and nextest's experimental setup script are gone.wt-perf timelinealso buildswtfirst and takes the artifact path from cargo, rather than measuring whatever stale sibling binary it found. (#3712) -
Benchmarks are organized by repository shape: Groups now select semantic
FixtureRecipes and share table-driven cases, keeping separate fixtures only for a controlled contrast, a destructive precondition, or disproportionate setup cost. The real-repository list benchmarks share one pinnedrust-lang/rustfixture, and the scheduled workflow converts Criterion estimates withjqinstead of a one-off Python script. (#3721) -
azandteaspans group undernetworkinwt-perf timeline: The trace exporter'snetworkbucket recognized onlyghandglab, so the other two forge CLIs rendered uncategorized. (#3689)
Install worktrunk 0.72.0
Install prebuilt binaries via shell script
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.72.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.72.0/worktrunk-installer.ps1 | iex"; git-wt config shell installInstall prebuilt binaries via Homebrew
brew install worktrunk && wt config shell installDownload worktrunk 0.72.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