Worktrunk is the most-starred git worktree manager in existence. Ten thousand four hundred and seventy-nine stars, 376 forks, a Rust binary that installs with one Homebrew command, and a README whose headline promise is that worktrees become as easy as branches so you can run “5-10+” coding agents in parallel without them stepping on each other.
It also shipped its first release exactly one year ago — 0.1.0 on 3 November 2025 — and is still on version 0.81.0. In those 351 days it published 153 releases. Its changelog is 537 KB. The single most recent release carries seven distinct breaking changes and arrived after the longest quiet stretch in the project’s history.
None of that is disqualifying. What matters for the audience the README targets — agents running in subshells, not humans at a prompt — is a narrower question: when the tool says it succeeded, did it?
I downloaded the official worktrunk-x86_64-unknown-linux-musl binary for v0.81.0 (29,099,264 bytes), ran it on ext4 with no shell integration installed, and drove every core command through about twenty test repositories. The tool is genuinely well built. It is also built around an assumption that quietly stops holding the moment a program instead of a person invokes it — the same class of gap I found auditing Scrapling’s adaptive matcher, where a score of 47% also arrived with no warning.
The parts that survived contact
Start with what holds up, because the criticism later only lands if this part is true.
Data safety is real. wt remove on a worktree containing an untracked file refuses, names the file, and exits 1:
$ wt remove dirty
✗ Cannot remove worktree: dirty has uncommitted changes
?? untracked.txt
↳ Commit or run git stash -u in the dirty worktree first, or to lose uncommitted changes, run wt remove --force dirty
$ echo $?
1
The worktree and the file both survive. A staged modification to a tracked file behaves the same way. Even --force refuses to be vague about what it destroys — it prints each discarded change before deleting:
◎ Removing forceme worktree (--force) & branch in background (same commit as main, _)
▲ Discarding uncommitted changes (--force):
?? notes.txt
That is the correct design, and it matches the project’s own AGENTS.md, which states the rule for contributors: “Prefer a clear failure over losing untracked files, uncommitted changes, or user data. Make cleanup a separate action, never a destructive side effect of an unrelated command.”
The engineering is not a wrapper script. A shallow clone gives 212 Rust files and 170,364 lines in src/, plus 75 files and 110,461 lines in tests/, containing 2,763 #[test] attributes and 2,340 #[rstest] attributes (32 ignored). The largest single source file is not the picker or the merge logic — it is src/config/deprecation.rs at 5,650 lines, the machinery for migrating configs that have moved.
Issue hygiene is strong. Sixty-five open issues against 10,479 stars is 6.2 per 1,000 stars. Comparable Rust git tools run three to six times higher:
| Project | Stars | Watchers | Watchers/Stars | Open issues | Issues per 1k stars |
|---|---|---|---|---|---|
| Worktrunk | 10,479 | 23 | 0.22% | 65 | 6.2 |
| delta | 32,650 | 111 | 0.34% | 502 | 15.4 |
| lazygit | 83,635 | 337 | 0.40% | 1,063 | 12.7 |
| jj (Jujutsu) | 32,348 | 99 | 0.31% | 1,258 | 38.9 |
| git-town | 3,390 | 32 | 0.94% | 54 | 15.9 |
| git-absorb | 5,756 | 18 | 0.31% | 31 | 5.4 |
| gitui | 22,620 | 93 | 0.41% | 357 | 15.8 |
I expected the 0.22% watcher ratio to be the story — twenty-three people have asked to be notified about a repository with ten thousand stars. It is not. Every comparable CLI here sits between 0.31% and 0.94%, so worktrunk is at the low end of a low-ratio class, not an outlier. Stars and watchers are simply different currencies for command-line tools, and a tool that spreads through blog posts and package managers accumulates the first far faster than the second.
The honest read of the repository signals is: this is a well-maintained project whose author is unusually responsive, not a star-farmed one.
The finding that matters: a failed post-start hook still exits 0
Here is a post-start hook, the hook type the documentation calls “the most common creation hook” because it “runs background tasks (dev servers, file copying, builds) without blocking worktree creation”:
# .config/wt.toml
[post-start]
setup = "echo doing setup; exit 7"
Run it the way an agent must run it — non-interactive, hooks approved by flag:
$ wt switch --create bg7 -y; echo "EXIT=$?"
✓ Created branch bg7 from main and worktree @ .../repo.bg7
↳ To customize worktree locations, run wt config create
▲ Cannot change directory — shell integration not installed
↳ To enable automatic cd, run wt config shell install
◎ Running post-start: setup (project) @ .../repo.bg7
EXIT=0
The hook failed. The exit status is zero. Nothing on stderr says so. The worktree exists, looks correct, and every check a supervising script can make — exit code, directory presence, wt list — reports success.
The failure is recorded, but only in a file:
$ cat .git/wt/logs/bg7/project/post-start/runner.log
✗ post-start project:setup failed (exit code 7)
And the central audit log does not carry it either:
{"ts":"2026-11-04T00:53:43Z","wt":".../wt switch --create bg7 -y",
"label":"post-start project runner","cmd":".../wt hook run-pipeline",
"exit":null,"dur_ms":null}
exit is null, and cmd names the runner, not the command that ran. The setup.log file next to it contains the hook’s own output — here the single line doing setup — so between two files you can reconstruct what happened. Nothing joins them, and no exit code carries the signal.
This is not a bug in the sense of an oversight; post-start is documented as non-blocking, and the docs tell you to “prefer post-start over pre-start unless a later step needs the work completed first.” But look at what people actually put in post-start. The project’s own approval prompt example is:
○ pre-start install: npm ci
○ pre-start build: cargo build --release
○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local
Dependency installation is exactly the thing that lives here. The three hook modes behave completely differently to a caller, and only one of them is visible to an exit code:
| Hook type | Foreground? | Hook fails with exit 7 | Worktree created? | Signal a script sees |
|---|---|---|---|---|
pre-start | yes (blocking) | wt switch exits 3 | yes — not rolled back | exit code |
post-start | no (background) | wt switch exits 0 | yes | none |
post-start + --no-hooks | skipped entirely | wt switch exits 0, hook never runs | yes | none |
The pre-start row deserves its own note: the hook fails, the exit code is 3, the message points at the fix (↳ To skip pre-start hooks, re-run with --no-hooks), and the worktree is left in place anyway. A failing gate does not undo the thing it gated.
The second: wt merge deletes the directory you are standing in
The most-advertised ergonomic win is that you never cd back to main:
“No cd back to main —
wt mergeruns from the feature worktree and merges into the target, like GitHub’s merge button.”
That is accurate, and it is also why the next fact is surprising. Run it from inside the feature worktree:
$ cd ~/repo.feat
$ wt merge main; echo "EXIT=$?"
◎ Merging 1 commit to main @ 12e4123 (no commit/squash/rebase needed)
* 12e4123 unique work
d.txt | 1 +
1 file changed, 1 insertion(+)
✓ Merged to main (1 commit, 1 file, +1)
◎ Removing feat worktree & branch in background (same commit as main, _)
▲ Worktree for main @ ~/repo, but cannot change directory — shell integration not installed
EXIT=0
$ git status
fatal: Unable to read current working directory: No such file or directory
The merge worked. The commit is on main. And the directory the shell was standing in was deleted about a second later, leaving the shell in a state where git — and most other tools — cannot run at all. pwd will still cheerfully print the deleted path, because bash caches it.
wt remove invoked from inside the worktree it removes does the same thing. So does wt switch when it cannot change directory: it creates the worktree, prints a triangle-marked warning, and exits 0.
For a human with shell integration installed, this is fine: the wrapper function evaluates the cd for you, and the tool prints “Switched to worktree for main”. For a bash -c script, a CI step, or an agent’s tool call, there is no wrapper, no cd, and no failure signal. The three wt switch outcomes that all exit 0:
| Situation | What happens | Exit code |
|---|---|---|
| Shell integration absent | worktree created, no cd, warning printed | 0 |
| Binary invoked by absolute path | same, with the message “shell integration wraps wt” | 0 |
| Inside feature worktree when merge removes it | cwd deleted, next git call fatal | 0 |
The tool detects all three conditions precisely and describes each one correctly in prose. It just does not consider any of them a failure.
The third: hooks under -y run, and approvals are never written
Project hooks — anything in the repository’s .config/wt.toml — require approval on first run, because they are arbitrary code from a repo you may have cloned five minutes ago. That gate is real:
$ wt switch --create victim2 # non-interactive, hook not approved
▲ repoA needs approval to execute 1 command:
○ post-start exfil:
touch /tmp/hook_marker && date +%s >> /tmp/hook_marker
✗ Cannot prompt for approval in non-interactive environment
↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add --yes
$ echo $?
1
Note the collateral: exit 1, and the worktree is not created at all. In an automated loop nothing runs until an operator intervenes, which is the right default for a human and a blocking wall for a pipeline.
The documented escape hatches are --yes and --no-hooks. They are not equivalent:
| Flag | Hook executes | approvals.toml written | commands.jsonl entry | Safe in a cloned repo? |
|---|---|---|---|---|
| (none, no TTY) | no — aborts | no | no | yes (blocks everything) |
--no-hooks | no | no | no | yes |
--yes | yes, immediately | no | yes, exit:null | trusts the repo |
wt config approvals add --yes | yes | yes, exact command string | yes | yes, after review |
wt config approvals add writes the literal command text into ~/.config/worktrunk/approvals.toml, keyed by project path:
[projects."/home/you/repo"]
approved-commands = [
"echo HOOK_RAN_IN=$(pwd)",
]
That is a good design: change one character of the hook and approval is required again. But -y skips the whole gate — it does not record what it ran, only that a pipeline ran. If your agent loop uses -y (and every non-interactive loop has to choose one of these four), then any repository you clone can execute code on your machine at wt switch time, with no approval record and an audit log entry whose exit field is null.
--no-hooks is the flag a careful team should be using, and it is documented in one line inside the hook security section rather than anywhere in the quickstart.
The fourth: hash_port gives each worktree a port, not a unique one
The README lists “Dev server per worktree — hash_port template filter gives each worktree a unique port”. The filter table documents the output range as 10000–19999. Measuring it:
$ wt switch feat-a -x echo -- '{{ branch | hash_port }}'
15882
$ wt switch feat-b -x echo -- '{{ branch | hash_port }}'
18703
Stable across runs, distinct per branch. Then the same branch name in two different repositories:
repoA/shared-name = 14075
repoB/shared-name = 14075
Identical. The hash covers the branch name and nothing else, so main, develop, fix/ci, or feature/login — the names that recur in every repository ever created — map to one number across all of them. Two projects with a branch called shared-name launching a dev server will fight over port 14075, and the symptom is a bind error in whichever one starts second, which is not obviously a Worktrunk problem.
The docs do contain the fix, in a code comment under “Hash any string, including concatenations”:
# Unique port per repo+branch combination
[post-start]
dev = "npm run dev --port {{ (repo ~ '-' ~ branch) | hash_port }}"
Measured, that gives 17635 and 19522 for the two repositories. So this is a documentation-placement finding rather than an unfixed defect: the correct recipe exists, half a page below the recipe most people will copy.
153 releases in 351 days, and what that costs
Release cadence is the most legible number in the repo, and it is not a straight line:
| Month | Releases | Month | Releases | |
|---|---|---|---|---|
| 2025-11 | 10 | 2026-05 | 9 | |
| 2025-12 | 25 | 2026-06 | 9 | |
| 2026-01 | 35 | 2026-07 | 9 | |
| 2026-02 | 13 | 2026-08 | 4 | |
| 2026-03 | 11 | 2026-09 | 5 | |
| 2026-04 | 22 | 2026-10 | 1 |
Averages: 3.05 releases per week, median gap between releases 32.5 hours, shortest gap 0.3 hours, longest 552 hours (23 days) immediately before v0.81.0. The January peak — 35 releases in one month — has cooled to roughly one a month, which is what a project maturing past its discovery phase looks like.
The changelog tells the more useful story. Across 140 documented versions there are 1,393 feature bullets and 146 occurrences of “Breaking”, spread over 33 versions. v0.81.0 alone contains seven distinct breaking changes, including:
- Hooks no longer receive their context as JSON on stdin (pass template arguments or
tojsoninstead). - Template rendering moved to MiniJinja 3, so objects and lists now render Python-style (
{'port': 3000}) rather than as JSON,roundrounds half-to-even, and division by zero errors. - Filters such as
sanitizenow reject an undefined input, so{{ upstream | sanitize }}needs a guard on a branch with no upstream. wt step pruneruns pre-remove hooks concurrently — hooks writing shared state must coordinate.wt switch -andwt remove -resolve differently, including in bare repositories.
The project’s own AGENTS.md sets the boundary it intends to hold: “Config formats and CLI arguments are protected interfaces; internal APIs and output formatting are flexible.” Template semantics, hook stdin contracts and prune concurrency are none of those three things — they are runtime behaviour, which is exactly the category an agent script hard-codes. A prompt file written against 0.79 is not wrong about the CLI; it is wrong about what the CLI does.
The silent cost of that policy is visible in the source tree: src/config/deprecation.rs is 5,650 lines, the largest file in a 170,364-line codebase. And deprecated hook names are aliased without a word:
$ cat .config/wt.toml
[post-create]
setup = "echo old-name-hook-ran"
$ wt hook show
PROJECT HOOKS @ .../repo/.config/wt.toml
❯ post-start setup: (requires approval)
echo old-name-hook-ran
$ wt config update
○ No deprecated settings found
The key was renamed — pre-create/post-create became pre-start/post-start — the file was not rewritten, and wt config update reports nothing to update. Anyone copying a hook block from a post written before the rename gets a hook that runs under a different name than the one in their file, permanently, with no migration prompt.
For contrast, the same command handles bad input well: an unknown section prints ▲ Project config has unknown field bogus-section (will be ignored) and exits 0, while malformed TOML exits 1 with a precise unclosed table, expected ']' at line and column.
Two numbers that change adoption decisions
Copy-on-write is not free on ext4. The README says ten worktrees get target/, node_modules/ “without building or copying them (on APFS, btrfs, and XFS)”. The FAQ is blunter: reflinks work on APFS, btrfs and XFS, “not ext4 or NTFS”. I measured the fallback on ext4 with a 191 MB node_modules:
$ wt step copy-ignored --to cow
✓ Copied 1 file · 190.7 MiB (full copy)
187 ms, and df available dropped from 29,532 MB to 29,341 MB — a real 191 MB duplicate. Plain cp -a of the same tree took 219 ms. The tool does label the fallback “(full copy)”, which is honest; but ten worktrees on the default Linux filesystem is 1.9 GB of duplicated build cache, and the word “full copy” scrolls past in a line of green checkmark output.
wt list is fast enough for a prompt and slow enough to notice. On a 400-file repo:
| Worktrees | wt list | git worktree list |
|---|---|---|
| 1 | 43 ms | — |
| 2 | 34 ms | — |
| 5 | 65 ms | — |
| 10 | 94 ms | — |
| 20 | 138 ms | 6 ms |
138 ms at twenty worktrees is fine for a command you type. It is not fine for anything called per keystroke, which is why the project ships a separate wt list statusline subcommand for prompt integrations.
The detail in the README that deserves more attention
Halfway down the README, inside an HTML comment above the star-history chart, is this:
“The token is fine-grained and reaches only this repo. Its Contents permission has to be read and write — write access is what GitHub accepts as proof of collaborator status, so rotating to a read-only token brings the placeholder straight back. Publishing the ciphertext is safe on its own; what the scope buys is a bound on star-history, which decrypts it and so holds a credential that can push here until it expires 2027-08-16 — at which point the chart reverts to the placeholder with nothing else to signal it.”
Read that carefully. To make a star-count chart render, GitHub requires proof of collaborator status, and the proof the project found is a fine-grained token with write access to the repository’s contents. That token is encrypted with star-history’s public key and pasted into the README of a public repository. Star-history holds the decrypted credential until 16 August 2027. There is no revocation on this side, and the README notes there will be no signal when it lapses.
The reasoning is disclosed, the scope is bounded to one repository, and a third-party chart placeholder is a trivial problem in the scheme of things. But the trade — a writable credential to the default branch, held off-site for two years, published as ciphertext, to fix a rendering bug on an image — is the kind of decision that gets made in a five-minute window and then outlives everybody who remembers making it. If you are about to adopt a tool whose maintainer accepts that trade for a chart, read the rest of their security posture with that prior in mind. Here the rest of the posture holds up well, which makes the token the anomaly rather than the pattern.
What I would actually do
The recommendation is not “avoid Worktrunk”. The deterministic parts — worktree creation, branch resolution, dirty-state refusal, --force transparency — are better than the shell scripts most teams hand-roll, and the test suite is large enough that behaviour changes are likely intentional rather than accidental.
The recommendation is to stop treating it as a shell convenience and start treating it as a component with an exit-code contract that does not cover the interesting cases.
1. Never rely on wt switch having changed your directory. Resolve the path and cd yourself, or pass the path explicitly to every following command. If you need the path in a template, the documented helper is expected_worktree_path_of_branch.
2. Never call wt merge or wt remove from inside the worktree they will delete. cd to the primary worktree first. This is one line in a wrapper and it removes an entire class of “fatal: Unable to read current working directory” failures. If you would rather not depend on a wrapper at all, the thinner git-worktree-runner approach makes the same isolation trade with far less behaviour to re-learn.
3. Pin the version, and read breaking changes on upgrade. 153 releases means brew upgrade is a behavioural change roughly three times a week. Pin the exact version in CI, and diff the changelog for the word “Breaking” before moving.
4. Choose --no-hooks for untrusted repositories, and pre-approve exact commands for trusted ones. -y is the flag that makes the consent gate disappear without leaving a record of what it allowed.
5. Put post-start failures into your own signal. After wt switch, check the runner log before starting work:
wt switch --create "$branch" -y >/dev/null || exit 1
log=".git/wt/logs/$branch/project/post-start/runner.log"
if [ -s "$log" ] && grep -q '✗' "$log"; then
echo "worktree setup failed:"; cat "$log"; exit 1
fi
6. Measure your own filesystem before budgeting disk. If df -T says ext4, copy-ignored is a copy, and ten agents is ten copies.
FAQ
Is Worktrunk safe to use with parallel coding agents?
For creating and switching worktrees, yes — that part is deterministic and refuses destructive actions. The risk is the two places where it assumes a human: directory changes and background hooks. Both are handled correctly by shell integration and by pre-start, and both fail invisibly without them.
Does wt switch change my current directory?
Only if shell integration is installed and the command is invoked through the shell wrapper. Called as a binary from a script, it creates the worktree, prints a warning, and exits 0 without moving you.
Why did my post-start hook fail without any error?
post-start hooks run in the background so they do not block worktree creation. Their exit status is not propagated to wt switch, and the console only shows the hook starting. The failure text lands in .git/wt/logs/<branch>/project/post-start/runner.log.
Is -y the same as approving the hooks?
No. -y bypasses the prompt for that invocation and does not write to approvals.toml. Use wt config approvals add --yes to record an approval that persists, or --no-hooks to skip project hooks entirely.
Why do two of my projects get the same dev-server port?
hash_port hashes only the branch name. Use {{ (repo ~ '-' ~ branch) | hash_port }} to include the repository, which the docs show under “Hash any string, including concatenations”.
Does Worktrunk’s copy-on-write cache work on Linux?
On btrfs and XFS yes. On ext4 and NTFS there is no reflink support, so wt step copy-ignored performs a full copy — a measured 190.7 MiB duplicated for a 191 MB node_modules, at 187 ms per worktree.
Is the project healthy? On the signals that predict maintenance, yes: 82 contributors, a large test suite, and an open-issue density roughly four times lower than comparable Rust git tools. The real cost is churn, not neglect — 146 breaking-change mentions across 140 documented releases, on a project still labelled 0.x.
The one-line version
Worktrunk solves a real problem well enough to have earned its stars, and it was designed for a person at a shell prompt with a wrapper function loaded. Point an agent at it without that wrapper and the tool will keep succeeding at a step it did not perform — which is the one failure mode a supervising loop cannot detect, because the loop was told everything went fine.