A registered worktree's path was trusted to still hold that worktree.
Three defects followed, one of them destroying data, and the check that
would have caught them — where it existed at all — was `Path::exists()`.
## `wt remove --force` deleted an unrelated repository
A clone that came to sit at a stale registration's path was removed
whole, including uncommitted work and, for a repo never pushed, the only
copy of its objects. wt's own hint routed the user there: the dirty gate
reads `git status` in that directory, reports the occupant's changes as
this worktree's, and offers `--force` as the cure.
```console
$ wt remove feature
✗ Cannot remove worktree: feature has uncommitted changes
?? precious.txt # ← the other repo's file
↳ ... to lose uncommitted changes, run wt remove --force feature
```
git refuses that same removal, `--force` included (`validation failed …
is not a .git file`). Worktrunk's fast path renames the directory into
trash rather than asking git to, so git's validation never ran.
`ensure_belongs_to_repo` makes it, comparing the directory's git dir
against this repository's: a linked worktree's sits under
`<common>/worktrees/`, the main worktree's *is* the common dir, anything
else answers to someone else. One comparison covers both worktree kinds
and also rejects a `.git` file pointing at another repo, which git's
shape test accepts.
It runs at planning, ahead of the dirty gate, and again at the rename
for callers that stage without planning. Now:
```console
$ wt remove --force feature
✗ Directory @ ../repo.feature is not this repository's worktree
↳ Removing it could destroy unrelated data; move the directory aside, then run git worktree prune
```
## A recreated worktree directory leaked git's exit 128
`wt switch`, `wt merge`, and `wt step push` walked into `git rev-parse
--git-dir failed (exit 128)`. Two of them probed `Path::exists()` first,
which a deleted-and-recreated directory passes; the third asked nothing.
`worktree_is_unusable` is the union of both tests, because neither
implies the other. `exists()` catches the absent directory; git's
`prunable` catches the recreated one. `prunable` alone is *not* the
wider test it looks like — git withholds the attribute from a **locked**
worktree even when its directory is gone, since prunability is its
pruning policy and a lock means "don't prune this":
```console
$ git worktree list --porcelain # wt2 locked, all three directories removed
worktree /tmp/ptest/wt1
prunable gitdir file points to non-existent location
worktree /tmp/ptest/wt2
locked removable media # ← no prunable line
worktree /tmp/ptest/wt3
prunable gitdir file points to non-existent location
```
A locked worktree on an unmounted volume is exactly what
`prepare_worktree_removal`'s lock guard exists for, so a `prunable`-only
test would read it as healthy. All three commands now give the message
the merely-deleted case already gave.
`wt remove` keeps `exists()`, deliberately: there it is the precondition
for the cleanup path rather than a health test, since
`prune_worktree_entry` unregisters via `git worktree remove`, which
skips validation only while the directory is absent. No scoped git
command clears the recreated case, so it reports and names the repo-wide
`git worktree prune` that does.
## `wt switch docs/` missed a branch sitting right there
Git's ref format forbids a trailing `/`, so the branch lookup never had
a candidate — and shell completion produces exactly that spelling
whenever a `docs` directory sits beside the branch. Selectors are
normalized before resolution.
## Resolving selectors through one ladder
The three fixes landed in three of the four places that assemble "expand
shortcuts, try the branch, try the path, classify the failure" by hand.
Each gated its path attempt on "did something rewrite this token?",
answered by comparing an expansion's output against its input:
| where | the comparison |
|---|---|
| `resolve_worktree` | `branch == name` |
| `plan_switch` | `target.branch == branch` |
| `target_worktree_at_path` | `target.filter(\|t\| *t == resolved)` |
| `resolve_base_ref` | `resolved == base` |
That is a fact the rewriting step knows, re-derived downstream from its
output, and it is wrong in both directions. A shortcut can expand to the
token it was given — `-` pointing at the branch you are already on — and
string equality reads that as a literal, turning the path arm back on
for a token nobody typed. Normalization breaks it the other way, which
is why the trailing-separator fix needed threading through three call
sites.
`Selector` carries the fact instead: `expand_shortcut` reports whether
it fired, `wt switch` reports its `pr:`/`mr:` dispatch and remote-prefix
strip, and `names_a_path()` replaces all four comparisons.
`resolve_selector` is the ladder, and `plan_switch` expands into it
rather than re-implementing its phases.
`names_a_path()` gates both path steps together — the worktree-by-path
lookup and the directory verdict — which is what `wt switch --create`
needs: the argument names a branch to create, so `branch_only()` takes
the arm off at the producer rather than each consumer re-testing
`create`.
It also reaches the directory verdict, so `ResolvedWorktree` gains
`NoWorktreeAtPath` and the four sites that called `path_selector_error`
themselves stop re-deriving it. The docstring defending that laziness
didn't survive checking — the function returns on `is_valid_branch_name`
before touching the filesystem, so every ordinary branch name already
short-circuited.
| | before | after |
|---|---|---|
| `normalize_selector` call sites | 3 | 1 |
| `path_selector_*` call sites | 4 | 2 |
| "was it rewritten?" comparisons | 4 | 0 |
## Navigating the diff
- `src/git/repository/mod.rs` — `Selector`, `normalize_selector`, the
new `ResolvedWorktree` variant.
- `src/git/repository/worktrees.rs` — `expand_shortcut`,
`expand_selector`, `resolve_selector`, `usable_worktree_for_branch`.
- `src/git/repository/working_tree.rs` — `ensure_belongs_to_repo`, the
ownership check.
- `src/git/remove.rs`, `src/commands/repository_ext.rs` — where it gates
removal, and why before the dirty gate.
- Call sites: `commands/worktree/switch.rs`,
`commands/worktree/push.rs`, `commands/merge.rs`, `commands/remove.rs`,
`git/repository/config.rs`.
## Size
Comments and docstrings are the largest share: the ownership check and
the four conditions behind the directory verdict all look like things to
simplify away, so the reason each exists is recorded where it's
enforced.
| | + | − |
|---|---:|---:|
| Production code | 277 | 142 |
| Comments & docstrings | 320 | 75 |
| Tests | 325 | 8 |
| Snapshots | 186 | 0 |
| Docs | 6 | 0 |
| **Total** | **1114** | **225** |
## Testing
Seven new tests. The data-safety one drives the real binary and asserts
the filesystem afterwards, not just the exit code — removal stages by
rename and deletes in a detached process, so a passing exit would not
have caught a staged-then-deleted tree. The others cover the recreated
directory (switch and remove), the trailing separator, `--create`
against a worktree registered at that path, and, at the unit boundary,
the four states of `worktree_is_unusable` — healthy, absent,
locked-and-absent, recreated — and the selector's path-ness, including
the degenerate case string equality got wrong. Each new test was
confirmed to fail with its fix reverted.
One more covers an omitted merge target in a repo whose default branch
can't be determined. `^` had a test for that error; the omitted-target
route to the same message had none. The gap predates this branch — the
closure is byte-identical to the one it replaces and codecov records
those lines as missed at the base commit too — but relocating them into
`resolve_target_selector` re-counted them as patch lines, which is what
surfaced it.
Local gate green: 4593 tests, lints, doctests, rustdoc under
`-Dwarnings`.
<details>
<summary>Behavioral matrix, verified against a build</summary>
```console
docs/ (trailing sep) ▲ Worktree for docs @ ../repo.docs
detached by path ▲ Worktree for detached worktree @ ../repo.det
leftover dir ✗ No worktree @ ../repo.leftover
recreated dir ✗ Worktree directory missing for rec
shortcut ^ ▲ Worktree for main @ ../repo
remove leftover ✗ No worktree @ ../repo.leftover
--base docs/ ✓ Created branch nf from docs
foreign-repo remove ✗ Directory @ ../repo.frn is not this repository's worktree
precious.txt survives
```
</details>
<details>
<summary>Also swept, and one thing left alone</summary>
Three more instances of the same shape, fixed here:
- `resolve_base_ref` was the fourth copy of the comparison, so `--base
docs/` now resolves too.
- `hint_for_repo` suggested `wt switch ^` after an existence probe a
recreated directory passes, pointing at a worktree the switch then
refuses.
- The pre-switch hook's `target` var used the bare shortcut expander, so
a hook saw `docs/` where the switch resolved `docs`.
The identical unborn/stale default-branch block in
`require_target_branch` and `require_target_ref` is extracted. The rest
of that pair differs in its existence predicate, extra arms, and final
error; sharing it would cost more in parameters than the duplication
does.
Left alone: `live_sibling_checkout` decides whether another worktree
still holds a branch during removal, and also uses `exists()`. Switching
it to `prunable` would make branch deletion *more* likely in a corner
case where the detached path already answers the other way. That is a
data-safety surface and a separate decision.
</details>
> _This was written by Claude Code on behalf of max-sixty_
20 KiB
+++ title = "FAQ" description = "Common questions about Worktrunk: comparison to git worktree and branch switching, bare repos, TUI support, and more." weight = 25
[extra] group = "Reference" +++
How does Worktrunk compare to alternatives?
vs. branch switching
Branch switching uses one directory: uncommitted changes from one agent get mixed with the next agent's work, or block switching entirely. Worktrees give each agent its own directory with independent files and index.
vs. Plain git worktree
Git's built-in worktree commands work but require manual lifecycle management:
{% terminal() %}
Plain git worktree workflow
git worktree add -b feature-branch ../myapp-feature main cd ../myapp-feature
...work, commit, push...
cd ../myapp git merge feature-branch git worktree remove ../myapp-feature git branch -d feature-branch {% end %}
Worktrunk automates the full lifecycle:
{% terminal() %} wt switch --create feature-branch # Creates worktree, runs setup hooks
...work...
wt merge # Merges into default branch, cleans up {% end %}
No cd back to main — wt merge runs from the feature worktree and merges into the target, like GitHub's merge button.
What git worktree doesn't provide:
- Consistent directory naming and cleanup validation
- Project-specific automation (install dependencies, start services)
- Unified status across all worktrees (commits, CI, conflicts, changes)
vs. git-machete / git-town
Different scopes:
- git-machete: Branch stack management in a single directory
- git-town: Git workflow automation in a single directory
- worktrunk: Multi-worktree management with hooks and status aggregation
These tools can be used together—run git-machete or git-town inside individual worktrees.
vs. Git TUIs (lazygit, gh-dash, etc.)
Git TUIs operate on a single repository. Worktrunk manages multiple worktrees, runs automation hooks, and aggregates status across branches. TUIs work inside each worktree directory.
Does Worktrunk support stacked branches?
Not natively — stacked-branch workflows are a large design space, so Worktrunk treats them as an extension rather than a built-in. worktrunk-sync is a community tool that auto-detects the branch dependency tree from git history and rebases each branch onto its parent in topological order. Install with cargo install worktrunk-sync and run as wt sync (via custom subcommands).
How do I move uncommitted changes to a new worktree?
Stash the changes, create the worktree, then pop:
{% terminal() %} git stash push -u # -u also stashes untracked files wt switch --create feature # new branch off the default branch git stash pop # changes reappear in the new worktree {% end %}
The stash lives in the shared .git directory, so it's reachable from the new worktree. The original branch is left clean.
wt switch --create bases the new branch on the default branch. To base it on the current commit instead, pass --base=@ (needed when the current branch has commits beyond the default branch).
There's an issue with my shell setup
If shell integration isn't working (auto-cd not happening, completions missing, wt not found as a function), the fastest path to a fix is using Claude Code with the Worktrunk plugin:
- Install the Worktrunk plugin in Claude Code
- Ask Claude to debug the Worktrunk shell integration
Claude will run wt config show, inspect the shell config files, and identify the issue.
If Claude can't fix it, please open an issue with the output of wt config show, the shell (bash/zsh/fish), and OS. (And even if it fixes the problem, feel free to open an issue: non-standard success cases are useful for ensuring Worktrunk is easy to set up for others.)
What does -v / -vv do?
Three verbosity levels. Each is a superset of the previous one.
| Level | Stderr | Files (.git/wt/logs/) |
Use case |
|---|---|---|---|
| (none) | Warnings only | — | Normal use |
-v |
+ Info: hook output, alias template variable resolution | — | Debugging hooks/aliases |
-vv |
Same as -v |
+ trace.log, trace.jsonl, subprocess.log, diagnostic.md |
Filing a bug |
At -vv, debug-level records (command lines, in-process spans, bounded subprocess preview) route to trace.log instead of stderr — so the terminal stays readable while the deep trace lands on disk. A one-line pointer on stderr shows where the files went.
The -vv files have distinct audiences: trace.log is the human trace (bounded, gistable), trace.jsonl the same records for machines, subprocess.log the raw uncapped subprocess output, and diagnostic.md a bug-report bundle. Each is described in wt config state logs.
RUST_LOG overrides the flag baseline when set (RUST_LOG=debug wt -v lifts -v to debug-on-stderr).
The flags only reach a command you type; shell completion runs as its own process with nowhere to pass one. Set WORKTRUNK_VERBOSE=0|1|2 to apply the level to every invocation, completion included — it's the env-var equivalent of -v/-vv, so level 2 writes the same trace.log/trace.jsonl/subprocess.log/diagnostic.md files. An explicit -v/-vv on a command raises the level further but never lowers this baseline. To profile a slow tab-completion, run it the way your shell does — e.g. WORKTRUNK_VERBOSE=2 COMPLETE=fish wt -- wt switch '' — then render the result with wt config state logs profile.
What files does Worktrunk create?
1. Worktree directories
Created by wt switch <branch> when switching to a branch that doesn't have a worktree. Use wt switch --create <branch> to create a new branch. Default location is ../<repo>.<branch> (sibling to main repo), configurable via worktree-path in user config.
To remove: wt remove <branch> removes the worktree directory and deletes the branch.
2. Config files
| File | Created by | Purpose |
|---|---|---|
~/.config/worktrunk/config.toml |
wt config create |
User preferences |
~/.config/worktrunk/approvals.toml |
Approving project commands | Approved hook and alias commands |
.config/wt.toml |
wt config create --project |
Project hooks (checked into repo) |
User config location: $XDG_CONFIG_HOME/worktrunk/ (or ~/.config/worktrunk/) on Linux/macOS, %APPDATA%\worktrunk\ on Windows.
To remove: Delete directly. User config: rm ~/.config/worktrunk/config.toml. Project config: rm .config/wt.toml (and commit).
3. Shell integration
Created by wt config shell install:
- Bash: adds line to
~/.bashrc - Zsh: adds line to
~/.zshrc(or$ZDOTDIR/.zshrc) - Fish: creates
~/.config/fish/functions/wt.fishand~/.config/fish/completions/wt.fish - Nushell : creates
wt.nuin Nushell's user vendor-autoload directory — the last entry of$nu.vendor-autoload-dirs, under$nu.data-dir(typically~/.local/share/nushell/vendor/autoloadon Linux,~/Library/Application Support/nushell/vendor/autoloadon macOS) - PowerShell (Windows): creates both profile files if they don't exist:
Documents/PowerShell/Microsoft.PowerShell_profile.ps1(PowerShell 7+)Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1(Windows PowerShell 5.1)
PowerShell detection on Windows: When running from cmd.exe or PowerShell, both PowerShell profile files are created automatically. When running from Git Bash or MSYS2, PowerShell is skipped (use wt config shell install powershell to create the profiles explicitly).
To remove: wt config shell uninstall.
4. Metadata in .git/ (automatic)
Worktrunk stores small amounts of cache and log data in the repository's .git/ directory:
| Location | Purpose | Created by |
|---|---|---|
git config worktrunk.* |
Cached default branch, switch history, branch markers, custom variables | Various commands |
.git/wt/cache/{kind}/*.json |
Cached CI status, the largest PR/MR number seen (sizes the wt list CI column), and git command results (merge-tree, integration probes, diff stats, ancestry checks, ahead/behind counts, merge bases) |
wt list, wt merge, wt remove |
.git/wt/cache/summary/{branch}/{hash}.json |
Cached LLM branch summaries, content-addressed by diff hash | wt list --full, wt switch (when [list] summary = true) |
.git/wt/logs/{branch}/**/*.log |
Background hook output (nested per branch) | Hooks, background wt remove |
.git/wt/logs/commands.jsonl |
Command audit log (~2MB max) | Hooks, LLM commands |
.git/wt/logs/trace.log |
Human debug trace for issue reporting | Running with -vv |
.git/wt/logs/trace.jsonl |
Machine trace (one JSON object per record) | Running with -vv |
.git/wt/logs/subprocess.log |
Raw uncapped subprocess stdout/stderr (may be multi-MB) | Running with -vv |
.git/wt/logs/diagnostic.md |
Diagnostic report for issue reporting (leads with the performance profile) | Running with -vv |
.git/wt/trash/<name>-<timestamp> |
Staged worktree contents pending background deletion | wt remove |
None of this is tracked by git or pushed to remotes.
To remove: wt config state clear removes all worktrunk data — config keys, caches, markers, hints, variables, logs, and stale trash.
What Worktrunk does NOT create
- No files outside
.git/, config directories, or worktree directories - No global git hooks
- No modifications to
~/.gitconfig - No long-running background processes or daemons
What can Worktrunk delete?
Worktrunk can delete worktrees and branches. Both have safeguards.
Worktree removal
wt remove mirrors git worktree remove: it refuses to remove worktrees with uncommitted changes (staged, modified, or untracked files). The --force flag removes the worktree anyway, discarding all of those changes.
Removal also refuses, --force included, when the directory at a registered path has come to hold a different repository — a clone made there after the worktree was deleted, say. --force waives uncommitted changes, not the check for whose directory it is, and git worktree remove refuses the same case.
To protect a worktree from removal entirely (say it holds a local database), lock it:
{{ terminal(cmd="git worktree lock ../myproject.feature --reason WT_QUOT__Contains local database__WT_QUOT") }}
Locked worktrees show ⊞ in wt list. Neither git worktree remove nor wt remove (even with --force) will delete them. Unlock with git worktree unlock.
Branch deletion
By default, wt remove only deletes branches whose content is already in the default branch. Branches showing _ (same commit) or ⊂ (integrated) in wt list are safe to delete.
For the full algorithm, see Branch cleanup — it handles squash-merge and rebase workflows where commit history differs but file changes match.
Use -D to force-delete branches with unmerged changes. Use --no-delete-branch to keep the branch regardless of status.
A branch checked out in a second worktree is retained regardless, -D included. Deleting it would leave that worktree unable to resolve HEAD; only git worktree add --force produces that state.
Other cleanup
wt merge/wt step push— the target branch's checked-out worktree is updated to the merged commits, so a file those commits delete disappears from it, and an ignored file at a path they track is overwritten — the same result agit mergerun in that worktree would produce. Uncommitted changes at paths the merge doesn't touch stay in place, staged or not; one at a path it does touch refuses the merge upfront, naming the filewt remove— besides the target worktree, two cleanup mechanisms run. The removed worktree's owngit fsmonitor--daemon(git's per-worktree filesystem watcher undercore.fsmonitor=true, which would leak once its worktree is gone) is sentgit fsmonitor--daemon stop, then force-terminated (SIGTERM, thenSIGKILL) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes.git/wt/trash/entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans fromgit worktree remove,rm -rf, or a crashedwt)wt config state clear— removes all worktrunk data from.git/(config keys, caches, markers, hints, variables, logs, stale trash)wt config shell install— when migrating an integration to a new location, removes the file left at the old one: fishconf.d/wt.fish(nowfunctions/wt.fish) and nushell wrappers stranded under<config-dir>/vendor/autoload(now<data-dir>/vendor/autoload). The old path is where worktrunk's own wrapper lived and is named after the command being installed, so it's taken back whole without reading it — aconf.d/wt.fishleft in place would be sourced at startup and shadow the new wrapper anyway. Only that exact filename is touched, and each removal is printedwt config shell uninstall— removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fishfunctions/,conf.d/, andcompletions/; nushellvendor/autoload). Uninstall takes no command name, so it lists those directories and recognizes files by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone. An rc file belongs to the user, so a line qualifies only where it runs the init command: one that merely mentions it, inside a comment, anecho, or an alias body, stays. Every line uninstall does take is printed, before removal and again after
See What files does Worktrunk create? for details.
What commands does Worktrunk execute?
Worktrunk runs git commands internally and optionally runs gh (GitHub) or glab (GitLab) for CI status. Beyond that, user-defined commands execute in four contexts:
- User hooks (
~/.config/worktrunk/config.toml) — Personal automation for all repositories - Project hooks (
.config/wt.toml) — Repository-specific automation - LLM commands (
~/.config/worktrunk/config.toml) — Commit message generation and branch summaries - --execute flag — Explicitly provided commands
User hooks and user aliases don't require approval (you defined them). Commands from project hooks and project aliases require approval on first run. Approved commands are saved to the approvals file (approvals.toml). If a command changes, Worktrunk requires new approval.
Example approval prompt
{% terminal() %} ▲ repo needs approval to execute 3 commands:
○ pre-start install: npm ci ○ pre-start build: cargo build --release ○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local
❯ Allow and remember? [y/N] {% end %}
Use --yes to bypass prompts (useful for CI/automation).
Command log
All hook executions and LLM commands are recorded in .git/wt/logs/commands.jsonl — one JSON object per line. Fields: ts (timestamp), wt (the wt command that triggered it), label (what ran, e.g., pre-merge user:lint), cmd (shell command), exit (exit code, null for background), dur_ms (duration, null for background). The file rotates to commands.jsonl.old at 1MB, bounding storage to ~2MB.
View the log with wt config state logs get, or query directly:
{% terminal() %}
Recent commands
tail -5 .git/wt/logs/commands.jsonl | jq .
Failed commands
jq 'select(.exit != 0 and .exit != null)' .git/wt/logs/commands.jsonl {% end %}
Clear with wt config state logs clear.
Does Worktrunk work on Windows?
Yes. Core commands, shell integration, and tab completion work in both Git Bash and PowerShell. See installation for setup details, including avoiding the Windows Terminal wt conflict.
Git for Windows required — Hooks use bash syntax and execute via Git Bash, so Git for Windows must be installed even when PowerShell is the interactive shell.
The wt switch interactive picker runs on Windows too, on skim's crossterm backend.
How does Worktrunk determine the default branch?
Worktrunk checks the local git cache first, queries the remote if needed, and falls back to local inference when no remote exists.
If the remote's default branch has changed (e.g., renamed from master to main), clear the cache with wt config state default-branch clear.
For full details on the detection mechanism, see wt config state default-branch --help.
My for-each or --execute alias prints the same value in every worktree
An alias body renders once at dispatch, in the invoking worktree's context, so a per-worktree variable like {{ branch }} is baked to that one worktree's value before the nested wt command iterates. Every worktree then sees the same value.
Confirm it with wt config alias dry-run <name>: if the value is already substituted (e.g. … echo branch=main), it was baked at dispatch.
To defer a variable to the nested command, wrap it as {% raw %}{{ branch }}{% endraw %}; for wt step for-each, also keep it inside a quoted sh -c '…' so the alias's shell doesn't word-split it. See deferring expansion in an alias. A repo-level variable like {{ default_branch }} is unaffected — it is identical in every worktree.
Installation fails with C compilation errors
Errors related to tree-sitter or C compilation (C99 mode, le16toh undefined) can be avoided by installing without syntax highlighting:
{{ terminal(cmd="cargo install worktrunk --no-default-features --features cli") }}
This disables bash syntax highlighting in command output but keeps all core functionality. The syntax highlighting feature requires C99 compiler support and can fail on older systems or minimal Docker images.
Running tests (for contributors)
Quick tests
{{ terminal(cmd="cargo test") }}
Full integration tests
Shell integration tests require bash, zsh, fish, nushell, and pwsh, plus jq:
{{ terminal(cmd="cargo test --test integration --features shell-integration-tests") }}
How can I contribute?
- Star the repo
- Try it out and open an issue with feedback — even small annoyances
- What worktree friction does Worktrunk not yet solve? Tell us
- Send to a friend
- Post about it on X, Reddit, or LinkedIn