feat: share Claude Code's worktree folder, by default reuse existing branches, and guard the main worktree - #8
Open
stilliard wants to merge 7 commits into
Open
feat: share Claude Code's worktree folder, by default reuse existing branches, and guard the main worktree#8stilliard wants to merge 7 commits into
stilliard wants to merge 7 commits into
Conversation
Worktrees went to a sibling of the repo, so wt and Claude Code each had their own set in different places. Default to .claude/worktrees/<branch> inside the repo, where Claude Code puts them, so both tools see the same worktrees. Repos will want .claude/worktrees/ in .gitignore, as the worktrees now sit inside the working copy. wt mk always passed -b, so it could only ever start a new branch. It now checks out an existing local branch as is, creates a tracking branch for one that only exists on origin, and creates the branch otherwise. The repo root is now resolved via git-common-dir rather than show-toplevel, so running wt mk from inside a worktree creates the next one alongside it rather than nested in it - which the sibling layout hid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wt rm resolves a name against branch names and directory basenames, so `wt rm master` or the repo's own folder name resolves the main worktree. git refuses to remove it, but only after wt has already cd'd there and run the pre-rm hook - and a pre-rm hook is exactly the kind of thing that clears caches or temp dirs, so it would have run destructively against the main checkout. Now worktrees live inside the repo, a name collision between the two is that bit more likely. Guard on the git-common-dir root before anything destructive, and use the same detection for the main-worktree checks in merged/ls rather than parsing it back out of git worktree list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hooks got WT_BRANCH and WT_PATH but no way to find the repo they belong to: _WT_HOOK_ROOT is set for the duration of the wt function call, not exported to the hook process. A hook that wants to run a setup script kept in the repo had to rediscover the root itself, and doing that from the new worktree's cwd is exactly the git-common-dir dance wt already does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
_wt_mk/_wt_rm currently do not fail fast when _wt_root() fails, which can lead to incorrect root/path resolution before aborting.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Updates wt to align its worktree layout and behavior with Claude Code, while improving safety around main-worktree operations and enriching hook context.
Changes:
- Default
wt mkdestination moved to$repo/.claude/worktrees/<branch>(matching Claude Code’s layout) and root detection now works reliably from linked worktrees. wt mkcan reuse existing local branches and can create a local tracking branch when the branch only exists onorigin.wt rmnow refuses to act on the main worktree before running destructive hooks, and hooks now receiveWT_ROOT.
File summaries
| File | Description |
|---|---|
| wt.sh | Adds _wt_root() for main-worktree resolution, changes default worktree destination, reuses/tracks existing branches, guards main worktree removal, and adds WT_ROOT to hook env. |
| test/mk.bats | Updates expectations for new default destination and adds coverage for branch reuse/tracking, nested-worktree prevention, and WT_ROOT hook behavior. |
| test/rm.bats | Adds regression tests ensuring the main worktree cannot be removed and that pre-rm is not run for it. |
| test/helpers.bash | Adds wt_dest() helper to centralize expected default worktree path construction. |
| README.md | Documents the new default .claude/worktrees/<branch> layout, branch reuse behavior, and the new WT_ROOT hook env var. |
Review details
Suppressed comments (1)
wt.sh:246
- If
_wt_rootfails,_wt_rmwill continue with an emptyrootvalue, which can break the main-worktree guard and subsequentcd "$root"recovery paths. Fail fast when_wt_rootcan’t be determined.
local root; root=$(_wt_root)
local target
target=$(_wt_resolve "${1?usage: wt rm <name> [--claude] [--pre-hook P] [--post-hook P]}")
[ -z "$target" ] && { echo "wt: no worktree matching '$1'" >&2; return 1; }
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
_wt_root returns non-zero outside a git repo, but mk and rm carried on with an empty root, so the pre-mk hook lookup and the default dest resolved against / before git failed on its own. Bail out instead, which also drops the duplicate 'not a git repository' output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.claude/worktrees stays the default - sharing a folder with Claude Code
with no setup is the point, and wt already reads Claude's session state
elsewhere - but it shouldn't be the only option for anyone who doesn't
work that way. wt.path takes a template with {name} and {repo}, read
with plain git config so --local and --global both work, and
'../{repo}-{name}' gets the old sibling layout back.
A relative template resolves against the repo root rather than cwd, so
it means the same thing from inside any worktree, and a template with no
{name} is rejected up front rather than collapsing every branch onto one
path.
Worktrees inside the repo also now warn when the destination isn't
gitignored, which Claude Code users already have covered but anyone
pointing wt.path at an untracked folder wouldn't.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A relative wt.path joins onto the root as $root/../repo-foo, which still
string-matches "$root/*", so a sibling worktree looked like it was
inside the repo. check-ignore was then handed a path outside the work
tree and failed with 128, which the guard read as "not ignored" - so
the '../{repo}-{name}' layout the README recommends printed a git fatal
plus a bogus warning.
Collapse . and .. textually before the containment test - the
destination doesn't exist yet, so realpath/cd aren't options - and only
treat check-ignore's exit 1 as "not ignored" so an error can't
masquerade as one. The tests missed this by calling wt mk without run,
so nothing was asserted on the output; they now check both messages are
absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Worktrees now go in
.claude/worktrees/wt mkcreated worktrees as a sibling of the repo, sowtand Claude Code each kept their own set in different places —wt lsshowed one,EnterWorktreemade the other, and neither was wrong. The default is now$root/.claude/worktrees/<branch>, the same folder Claude Code uses, so both tools see the same worktrees andwt <name>navigates to them all.Slashes still become dashes, and an explicit
wt mk <branch> <path>still wins. Repos will want.claude/worktrees/in.gitignore, as the worktrees now sit inside the working copy.wt.pathoverrides it where that isn't what you want:{name}is the branch with slashes replaced,{repo}the repo's folder name. Read with plaingit config, so--localand--globalboth work with no extra handling, and worktrees share the main repo's config so it resolves the same from any of them. A relative template resolves against the repo root rather than cwd — same reasoning as the nesting bug below: a path that means different things depending on where you're stood is a bug waiting to happen. A template with no{name}is rejected up front, since it would collapse every branch onto one path and fail on the secondwt mkwith a confusing "already exists".The default stays
.claude/worktrees/deliberately. Sharing a folder with Claude Code with zero setup is the point of it, andwtalready reads Claude's session state for--claude, so this isn't a new dependency. The one cost of living inside the repo is needing.claude/worktrees/gitignored — Claude Code users have that already, sowt mkwarns rather than editing anyone's files:wt mkcould only ever start a new branchIt always passed
-b, sowt mk existing-branchfailed outright. Now:So picking up someone's pushed branch is
wt mk their-branch.originis hardcoded — fine for the single-remote case this is for, and worth revisiting alongside the config work rather than guessing a remote now.wt mkfrom inside a worktree nested the next one inside it_wt_mkends withcd "$dest", and$rootcame fromgit rev-parse --show-toplevel— which, from a linked worktree, is that worktree. So a secondwt mkresolved the root to the worktree it had just moved into:The sibling layout hid this, since the nested result was just an oddly-placed sibling. Putting worktrees inside the repo makes it structural. Added
_wt_root(), which resolves the main worktree viagit rev-parse --git-common-dir— relative to cwd in the main worktree, absolute in a linked one, socd "$(dirname ...)" && pwdhandles both._wt_mk,_wt_rmand the hook-root fallback all use it.Caught by a live smoke test, not the suite — every existing
mktest calledwt mkfrom the repo root.wt rmwould runpre-rmagainst the main worktree_wt_resolvematches branch names and directory basenames, sowt rm master— or the repo's own folder name — resolves the main worktree.git worktree removerefuses it, but only after_wt_rmhas alreadycd'd there and run thepre-rmhook. Apre-rmhook is exactly the sort of thing that clears a cache or temp dir; the one I'm adding on the rec side issudo rm -rf tmp/. So it would have run destructively against the main checkout and then reported a failure.Guarded on the
_wt_root()path before anything destructive:Worktrees living inside the repo makes a name collision between a worktree and the repo folder that bit more likely, but the bug predates that. The main-worktree checks in
_wt_ls --claudeand_wt_merged --rmnow use the same detection instead of re-parsinggit worktree list --porcelain.Hooks had no way to find the repo they belong to
Hooks got
WT_BRANCHandWT_PATH._WT_HOOK_ROOTis set as a prefix assignment on a function call, so it's scoped to that call and never exported to the hook process — a hook wanting to run a setup script kept in the repo had to rediscover the root itself, from a cwd that is the new worktree, i.e. the samegit-common-dirdancewtalready does.Hooks now also receive
WT_ROOT, for both.wt-hooks/<event>and ad-hoc--pre-hook/--post-hookscripts.Tests
79/79 passing (63 → 79), via
bats test/.wt_dest()intest/helpers.bashbuilds the expected default path, so themkexpectations moved with the default rather than being restated 18 times.origin-only branch getting an upstream, and a branch already checked out elsewhere failing without leaving a directory behind.wt mkfrom inside a worktree lands alongside it, not nested.wt rmrefuses the main worktree by branch name, and itspre-rmhook does not run — the second is the one that matters, since git refuses either way.post-mkreceivesWT_ROOTpointing at the main worktree when invoked from a linked one.wt.path: relative, absolute and{repo}templates; resolving identically from inside another worktree; a template with no{name}rejected without creating anything; and the gitignore warning firing and staying quiet in the matching cases.wt mkoutside a git repo fails without falling back to/(from Copilot's review).Each behavioural test was confirmed to fail against the pre-change code. The "fails cleanly when already checked out" case passes both before and after — kept as a regression guard on the new branch-reuse path, not as evidence of the fix.
Also smoke tested by hand outside the suite: two
wt mkruns in a scratch repo landing side by side, all threewt.pathforms (default,{repo}sibling,~-expanded) in one repo at once, and a fullwt mk/wt rmcycle against a real repo withpost-mk/pre-rmhooks doing actual setup and teardown.🤖 Generated with Claude Code