github max-sixty/worktrunk v0.72.0
0.72.0

3 hours ago

Release Notes

Improved

  • wt merge and wt step push leave 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's receive.denyCurrentBranch=updateInstead refuses any dirty worktree at all. That design entered refs/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-swap update-ref, then update-index --refresh plus read-tree -m -u in the target worktree (git's documented lenient push-to-checkout policy), 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, and refs/stash is never involved. The upfront check that names a conflicting file also reads --porcelain -uall now, so an untracked file inside an untracked directory is caught and named rather than slipping past a collapsed dir/ entry. (Breaking: the fast-forward path no longer runs a git push, so pre-push and the receive-side hooks — pre-receive, update, post-receive, post-update, and any push-to-checkout — no longer fire. A git merge run in the target wouldn't run them either, which is the line the module spec draws, and the new sync reimplements the lenient policy push-to-checkout documents rather than invoking the hook. Separately, a sync that can't apply now fails the command with the ref rolled back, where --no-ff previously 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, or gitea as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS, so github.attacker.example carried 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 the github-personal SSH alias classify again, so CI status, wt switch --prs, and repo.provider work 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 on dev.azure.com / visualstudio.com — those are service domains rather than a brand in the host — and userinfo still resolves to the network host, so https://github.com@attacker.example/… is attacker.example. The github-personal diagnostic 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 gained forge.platform and forge.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-commands matches by the same rules, but only a hand-written key is ever a pattern: wt config approvals add records under the exact project identifier, and clear empties 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=json schema 2 has a published JSON Schema: The contract is at worktrunk.dev/schema/list-v2.json, and wt list --print-schema prints the same document. Four fields that were bare strings — checks.status, display.state, default_branch.integration.reason, and worktree.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=json reports 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 separate stale list carries approvals whose command template has changed since, which --yes would otherwise silently re-approve; it co-occurs with all three states, so it isn't a fourth. Separately, removal's branch_deleted boolean collapsed five internal outcomes into two values, reporting a lost CAS race and an explicit --no-delete-branch as the same false, and a deferred deletion as true on intent alone. branch_outcome names them: deleted, deferred, not_attempted, retained_unmerged, retained_checked_out, retained_raced, retained_failed. (Breaking: branch_deleted is replaced, not supplemented, on wt remove --format=json and wt 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: The wt list table sliced head[..8] while --format=json's short_sha carried git's %h, so one commit read 1b9f1d96 in the table and 1b9f1d9 in JSON. wt switch --prs's log tab and wt 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, so core.abbrev = 12 no 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 merge and wt step push refuse 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 --continue would commit the synced tree as the step's result. The old fast-forward path got this check for free from receive.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 switch or wt remove keeps your subdirectory: wt switch and wt remove land you in the destination's matching subdirectory, but the resolution read the user's position from the wt process'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 finish from feature/apps/gateway dropped 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 rm alias no longer breaks the shell wrappers' cleanup: zsh, bash, and nushell all expand aliases when the wrapper's function is parsed, so a user's alias rm='rm -v' defined before the wrapper was installed baked itself into the cleanup. On zsh and bash that printed two lines of removed '/tmp/…' noise per wt invocation. On nushell it was worse: nushell raises on a non-zero external exit, so an rm alias 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 ^rm on nushell, whose builtin is kept on Windows where there is no external rm), with the nushell cleanup wrapped in try so it can never abort the wrapper again. (#3714, thanks @Ar4l), (#3732)

  • A failing --execute body no longer strands the nushell wrapper's temp files: The wrapper's --execute call wasn't wrapped in try, so a body exiting non-zero unwound the whole function — cleanup never ran, all three mktemp files 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 -3 and wt list statusline | head -1 exited 101 with failed printing to stdout: Broken pipe, and so did wt config state get --format=json | head -3. std's print!/println! panic on a BrokenPipe while 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 -3 had always exited cleanly. Every stdout surface now exits cleanly instead: the thirty-six --format=json call sites through one print_json, the human-read ones — the wt list table, --version, --help-md, --help-description, and wt config update --print — through anstream, and the two whose escapes are data rather than presentation — wt list statusline and the --help-page document — through a verbatim printer that drops a BrokenPipe without stripping their ANSI. (#3746, #3766)

  • wt list no longer colors a pipe: --help documents NO_COLOR and CLICOLOR_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, with CLICOLOR_FORCE=1 to keep color. NO_COLOR reaches the buffered table — the piped case, plus --no-progressive on a terminal — while the default progressive terminal rendering bakes each row's escapes in and is unchanged. (#3766)

  • --print-schema and the doc-generation help flags name the right command: --print-schema, --help-page, and --help-description each scanned argv for the subcommand, recognizing the binary by a /wt suffix or a target/ path component. Neither matches wt.exe under a backslash path, so on every Windows install all three read the binary's own path as the command: --print-schema exited 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 platform wt -C <path> list --print-schema answered No JSON schema for '<path>'. All three now take the name clap already parsed, and --print-schema prints through the same print_json as the --format=json surfaces, 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, so wt list --full showed a green cell for a PR blocked on an approval gate that had run nothing; EXPECTED was matched against the wrong field, and a run that failed to start (STARTUP_FAILURE) counted neither way. On GitLab, canceling and waiting_for_callback rendered the same empty cell as a branch with no pipeline at all. On Azure DevOps, an in-flight cancelling reported red before anything had finished, postponed rendered empty, and a terminal partiallySucceeded build 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 api copies 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 list logged Failed to parse tea api pulls JSON once per branch, and wt switch pr:<n> failed with This may indicate a Gitea API change, both blaming an API change for an API error. tea api --include writes 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 for 429 in 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)

  • wt installed under a dotted name generates shell integration for that name: binary_name derived the command from argv[0] with file_stem, which cuts at the last dot wherever it is, on every platform — so wt config shell init bash under wt.old emitted a wrapper for wt, a command the user may not have. It now strips only the executable suffix, case-insensitively. A non-UTF8 argv[0] is rejected with Invalid shell integration command name rather than quietly generating integration for a different command. (#3719)

  • A multibyte shell name no longer panics: extract_filename_from_path compared a name's last four bytes against .exe by slicing at len() - 4 with no char-boundary check, so SHELL=/bin/日本語 wt config show panicked. 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 prune removals no longer race the worktree registry: git worktree remove enumerates every entry under .git/worktrees/ and reads each sibling's commondir while 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 the claude agents view 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 remove died with not a git repository, and the session became undeletable. The hook now passes the worktree path to -C: a linked worktree's .git file 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 reached wt 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 .git entry. (#3754, thanks @judewang), (#3767, thanks @judewang for reporting)

  • The wt switch first-run offer previews the legacy files it removes: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper — a fish conf.d/wt.fish superseded by functions/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 removals wt config shell install previews. What gets removed is unchanged; only what the prompt discloses. (#3656)

  • wt list --branches counts a local branch containing / as local: The summary tally classified branch-only rows by branch.contains('/'), so a local feature/login counted 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=json folded them into its skipped set; the human tally counted only validation and executor skips, so it undercounted by the number of branches whose worktree-path template failed to expand. (#3688)

  • wt config create --project writes a resolvable link: The comment it writes into .config/wt.toml — and that wt config create --help prints — 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-include example renders as a terminal block: It was the only console block 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-checks fails five lints — struct worktrunk::git::LegacyForgeAlias and method Repository::legacy_forge_alias removed with the forge-classification revert, Repository::forge_platform_override removed in favor of one shared resolver, worktrunk::git::stage_worktree_removal gained two parameters, UserProjectOverrides gained a forge field, and the real-repo-benches feature 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_removal now 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)

  • -vv traces report a normal run's fork count: prewarm_at fast-pathed on a cache key that -vv's own logging setup populated before prewarm ran, so -vv skipped 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 using extensions.worktreeConfig also gained the preload, which they had never received, saving a fork per invocation. (#3705)

  • The test mock stub is the wt binary itself: cargo test --test integration neither built nor rebuilt the separate mock-stub helper package, so a fresh tree panicked and a warm one could run a stale stub. wt is 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, the default-members entry, and nextest's experimental setup script are gone. wt-perf timeline also builds wt first 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 pinned rust-lang/rust fixture, and the scheduled workflow converts Criterion estimates with jq instead of a one-off Python script. (#3721)

  • az and tea spans group under network in wt-perf timeline: The trace exporter's network bucket recognized only gh and glab, 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 install

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

Install prebuilt binaries via Homebrew

brew install worktrunk && wt config shell install

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