From dc14d03e601927c48a1991d38110a5c4ba04f928 Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Fri, 21 Aug 2026 14:50:33 +0100 Subject: [PATCH 1/7] feat(mk): use .claude/worktrees and reuse existing branches 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/ 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 --- README.md | 10 +++++- test/helpers.bash | 6 ++++ test/mk.bats | 82 ++++++++++++++++++++++++++++++++++++----------- wt.sh | 24 ++++++++++---- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e458e33..12eb4c5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Then reload your shell (`source ~/.zshrc`) or open a new terminal. ```sh wt # list all worktrees wt # cd into worktree by branch name -wt mk # create worktree as sibling of current repo and cd into it +wt mk # create worktree in .claude/worktrees/ and cd into it wt mk # create worktree at a specific path and cd into it wt rm # remove a worktree wt prune # prune stale worktree refs @@ -39,6 +39,14 @@ wt help # show usage Aliases: `add` → `mk`, `remove` → `rm`, `list` → `ls` +Worktrees are created in `.claude/worktrees/` inside the repo, the same place +Claude Code puts them, so both tools see the same set. Add `.claude/worktrees/` to your +`.gitignore` if it isn't already. Slashes in a branch name become dashes in the folder. + +`wt mk` reuses an existing branch where there is one - a local branch is checked out as +is, and a branch that only exists on `origin` gets a local tracking branch. Otherwise the +branch is created, from `--base` if given. + Tab completion works for subcommands and branch names in both bash and zsh, matching anywhere in the branch name (`wt api-webhook` → `worktree-api-webhook-error-alerts`). `wt merged` detects `main` or `master` automatically, or pass an explicit base: `wt merged develop`. It only lists candidates — run `wt rm ` yourself to remove them. (`wt prune` is unrelated: it just cleans up `git worktree` metadata for directories that were deleted outside of `wt rm`.) diff --git a/test/helpers.bash b/test/helpers.bash index b3dff5d..67fa001 100644 --- a/test/helpers.bash +++ b/test/helpers.bash @@ -29,6 +29,12 @@ wt_common_setup() { source "$WT_SH" } +# default worktree path wt mk uses for a branch, inside the test repo +wt_dest() { + local safe="${1//\//-}" + printf '%s' "$TEST_REPO/.claude/worktrees/$safe" +} + wt_common_teardown() { rm -rf "$TEST_REPO" "$TEST_REPO-feature" "$TEST_REPO-other" } diff --git a/test/mk.bats b/test/mk.bats index 2ab0377..1a62818 100644 --- a/test/mk.bats +++ b/test/mk.bats @@ -5,9 +5,9 @@ teardown() { wt_common_teardown; } # --- _wt_mk --- -@test "wt mk creates worktree as repo sibling" { +@test "wt mk creates worktree under .claude/worktrees" { local branch="my-feature" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" wt mk "$branch" [ -d "$expected" ] git worktree remove "$expected" @@ -15,7 +15,7 @@ teardown() { wt_common_teardown; } @test "wt mk cds into new worktree" { local branch="cd-test" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" wt mk "$branch" [ "$PWD" = "$expected" ] git worktree remove "$expected" @@ -23,7 +23,7 @@ teardown() { wt_common_teardown; } @test "wt add alias creates worktree" { local branch="via-add" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" wt add "$branch" [ -d "$expected" ] git worktree remove "$expected" @@ -31,7 +31,7 @@ teardown() { wt_common_teardown; } @test "wt mk replaces slashes in branch name with dashes" { local branch="type/my-thing" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-type-my-thing" + local expected="$(wt_dest "$branch")" wt mk "$branch" [ -d "$expected" ] git worktree remove "$expected" @@ -47,7 +47,7 @@ teardown() { wt_common_teardown; } @test "wt mk --base creates branch from specified base" { local branch="based-branch" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" wt mk --base feature "$branch" local base_commit; base_commit=$(git -C "$TEST_REPO-feature" rev-parse HEAD) local new_commit; new_commit=$(git -C "$expected" rev-parse HEAD) @@ -55,6 +55,52 @@ teardown() { wt_common_teardown; } git worktree remove "$expected" } +@test "wt mk reuses an existing local branch instead of failing" { + git branch -q existing-local + local want; want=$(git rev-parse existing-local) + local expected="$(wt_dest existing-local)" + wt mk existing-local + [ "$(git -C "$expected" rev-parse HEAD)" = "$want" ] + [ "$(git -C "$expected" rev-parse --abbrev-ref HEAD)" = "existing-local" ] + cd "$TEST_REPO" + git worktree remove "$expected" +} + +@test "wt mk tracks a branch that only exists on origin" { + local upstream; upstream=$(mktemp -d) + git init -q --bare "$upstream" + git remote add origin "$upstream" + git push -q origin HEAD:refs/heads/remote-only + git fetch -q origin + git update-ref -d refs/heads/remote-only 2>/dev/null || true + local expected="$(wt_dest remote-only)" + wt mk remote-only + local ok=1 + [ "$(git -C "$expected" rev-parse --abbrev-ref --symbolic-full-name @{u})" = "origin/remote-only" ] || ok=0 + cd "$TEST_REPO" + git worktree remove "$expected" + rm -rf "$upstream" + [ "$ok" -eq 1 ] +} + +@test "wt mk fails cleanly when the branch is checked out in another worktree" { + local expected="$(wt_dest feature)" + run wt mk feature + [ "$status" -ne 0 ] + [ ! -d "$expected" ] +} + +@test "wt mk from inside a worktree creates alongside it, not nested" { + wt mk first + [ "$PWD" = "$(wt_dest first)" ] + wt mk second + [ -d "$(wt_dest second)" ] + [ ! -d "$(wt_dest first)/.claude/worktrees/second" ] + cd "$TEST_REPO" + git worktree remove "$(wt_dest second)" + git worktree remove "$(wt_dest first)" +} + @test "wt mk errors on unknown flag" { run wt mk --bogus value branch [ "$status" -ne 0 ] @@ -65,7 +111,7 @@ teardown() { wt_common_teardown; } @test "post-mk hook is called with WT_BRANCH and WT_PATH" { local branch="hook-test" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" mkdir -p "$TEST_REPO/.wt-hooks" printf '#!/bin/sh\necho "branch=$WT_BRANCH path=$WT_PATH" > /tmp/wt-hook-out' > "$TEST_REPO/.wt-hooks/post-mk" chmod +x "$TEST_REPO/.wt-hooks/post-mk" @@ -77,7 +123,7 @@ teardown() { wt_common_teardown; } @test "hooks are skipped when .wt-hooks dir does not exist" { local branch="no-hook" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" run wt mk "$branch" [ "$status" -eq 0 ] git worktree remove "$expected" @@ -85,7 +131,7 @@ teardown() { wt_common_teardown; } @test "pre-mk hook failure aborts worktree creation" { local branch="pre-mk-abort" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" mkdir -p "$TEST_REPO/.wt-hooks" printf '#!/bin/sh\nexit 1' > "$TEST_REPO/.wt-hooks/pre-mk" chmod +x "$TEST_REPO/.wt-hooks/pre-mk" @@ -98,7 +144,7 @@ teardown() { wt_common_teardown; } @test "wt mk copies gitignored files listed in .worktreeinclude" { local branch="wti-copy" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" echo "*.env" > "$TEST_REPO/.gitignore" echo "SECRET=1" > "$TEST_REPO/prod.env" echo "*.env" > "$TEST_REPO/.worktreeinclude" @@ -113,7 +159,7 @@ teardown() { wt_common_teardown; } @test "wt mk copies gitignored files with special characters in their names" { local branch="wti-special" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" echo "*.env" > "$TEST_REPO/.gitignore" printf 'VAL=1' > "$TEST_REPO/wéird name.env" echo "*.env" > "$TEST_REPO/.worktreeinclude" @@ -128,7 +174,7 @@ teardown() { wt_common_teardown; } @test "wt mk does not copy untracked files that are not gitignored" { local branch="wti-skip" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" echo "notes" > "$TEST_REPO/notes.txt" echo "notes.txt" > "$TEST_REPO/.worktreeinclude" wt mk "$branch" @@ -142,7 +188,7 @@ teardown() { wt_common_teardown; } @test "wt mk succeeds when .worktreeinclude is absent" { local branch="wti-none" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" run wt mk "$branch" [ "$status" -eq 0 ] git worktree remove --force "$expected" @@ -153,7 +199,7 @@ teardown() { wt_common_teardown; } @test "wt mk --post-hook runs the ad-hoc script with WT_BRANCH and WT_PATH" { local branch="adhoc-post" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" local script="$TEST_REPO/adhoc.sh" printf '#!/bin/sh\necho "branch=$WT_BRANCH path=$WT_PATH" > /tmp/wt-adhoc-out\n' > "$script" chmod +x "$script" @@ -166,7 +212,7 @@ teardown() { wt_common_teardown; } @test "wt mk accepts flags after the branch name" { local branch="adhoc-trailing" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" local script="$TEST_REPO/adhoc.sh" printf '#!/bin/sh\necho "branch=$WT_BRANCH" > /tmp/wt-adhoc-out\n' > "$script" chmod +x "$script" @@ -179,7 +225,7 @@ teardown() { wt_common_teardown; } @test "wt mk --pre-hook failure aborts worktree creation" { local branch="adhoc-pre-abort" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" local script="$TEST_REPO/fail.sh" printf '#!/bin/sh\nexit 1\n' > "$script" chmod +x "$script" @@ -190,7 +236,7 @@ teardown() { wt_common_teardown; } @test "wt mk runs a non-executable --post-hook via bash" { local branch="adhoc-nonexec" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" local script="$TEST_REPO/plain.sh" printf 'echo "branch=$WT_BRANCH" > /tmp/wt-adhoc-out\n' > "$script" wt mk --post-hook "$script" "$branch" @@ -202,7 +248,7 @@ teardown() { wt_common_teardown; } @test "wt mk errors when --post-hook file does not exist" { local branch="adhoc-missing" - local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-$branch" + local expected="$(wt_dest "$branch")" run wt mk --post-hook /nonexistent/path.sh "$branch" [ "$status" -ne 0 ] [[ "$output" == *"hook file not found"* ]] diff --git a/wt.sh b/wt.sh index 864d55b..ff4f0de 100644 --- a/wt.sh +++ b/wt.sh @@ -1,3 +1,10 @@ +# the main worktree's root, even when called from inside a linked worktree +# (--git-common-dir is relative to cwd in the main worktree, absolute in a linked one) +_wt_root() { + local common; common=$(git rev-parse --git-common-dir) || return 1 + (cd "$(dirname "$common")" && pwd) +} + # resolve a worktree path by branch name or directory basename _wt_resolve() { git worktree list --porcelain | awk -v q="$1" ' @@ -150,7 +157,7 @@ _wt_cd() { # run a hook script from .wt-hooks/ if it exists and is executable _wt_run_hook() { local event="$1"; shift - local root="${_WT_HOOK_ROOT:-$(git rev-parse --show-toplevel)}" + local root="${_WT_HOOK_ROOT:-$(_wt_root)}" local hookfile="$root/.wt-hooks/$event" [ -x "$hookfile" ] || return 0 WT_BRANCH="$1" WT_PATH="$2" "$hookfile" @@ -182,7 +189,7 @@ _wt_copy_worktreeinclude() { done } -# create a new worktree as a sibling of the current repo (optional explicit path as second arg) +# create a new worktree under .claude/worktrees/ in the repo (optional explicit path as second arg) _wt_mk() { local pre_hook="" post_hook="" base="" local -a args @@ -198,13 +205,17 @@ _wt_mk() { done set -- "${args[@]}" local branch="${1?usage: wt mk [path] [--base B] [--pre-hook P] [--post-hook P]}" - local root; root=$(git rev-parse --show-toplevel) + local root; root=$(_wt_root) local safe="${branch//\//-}" - local dest="${2:-$(dirname "$root")/$(basename "$root")-$safe}" + local dest="${2:-$root/.claude/worktrees/$safe}" _WT_HOOK_ROOT="$root" _wt_run_hook pre-mk "$branch" "$dest" || return _wt_run_adhoc_hook "$pre_hook" "$branch" "$dest" || return if [ -n "$base" ]; then git worktree add "$dest" -b "$branch" "$base" || return + elif git show-ref --verify --quiet "refs/heads/$branch"; then + git worktree add "$dest" "$branch" || return + elif git show-ref --verify --quiet "refs/remotes/origin/$branch"; then + git worktree add --track -b "$branch" "$dest" "origin/$branch" || return else git worktree add "$dest" -b "$branch" || return fi @@ -229,7 +240,7 @@ _wt_rm() { esac done set -- "${args[@]}" - local root; root=$(git rev-parse --show-toplevel) + local root; root=$(_wt_root) local target target=$(_wt_resolve "${1?usage: wt rm [--claude] [--pre-hook P] [--post-hook P]}") [ -z "$target" ] && { echo "wt: no worktree matching '$1'" >&2; return 1; } @@ -359,7 +370,7 @@ Commands: wt cd into worktree by branch name wt cd cd into worktree (explicit form) wt ls [opts] list worktrees (same as bare wt) - wt mk [path] [opts] create worktree (default: sibling of repo) + wt mk [path] [opts] create worktree (default: .claude/worktrees/) wt rm [opts] remove a worktree wt prune prune stale worktree refs wt merged [base] [opts] list worktrees merged into base (default: main/master) @@ -377,6 +388,7 @@ Options (merged): Options (mk): --base BRANCH create the new branch from this commit-ish (default: HEAD) + without it, an existing local or origin branch is reused --pre-hook PATH run a script before the action (non-zero exit aborts) --post-hook PATH run a script after the action From 09ab8da531943068c177c1929b7f67e664ecdc8e Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Fri, 21 Aug 2026 15:46:38 +0100 Subject: [PATCH 2/7] fix(rm): refuse to remove the main worktree 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 --- test/rm.bats | 28 ++++++++++++++++++++++++++++ wt.sh | 7 ++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/test/rm.bats b/test/rm.bats index a2b946e..456a614 100644 --- a/test/rm.bats +++ b/test/rm.bats @@ -17,6 +17,34 @@ teardown() { wt_common_teardown; } [ ! -d "$TEST_REPO-other" ] } +@test "wt rm refuses to remove the main worktree by branch name" { + local branch; branch=$(git -C "$TEST_REPO" rev-parse --abbrev-ref HEAD) + run wt rm "$branch" + [ "$status" -ne 0 ] + [[ "$output" == *"refusing to remove the main worktree"* ]] + [ -d "$TEST_REPO/.git" ] +} + +@test "wt rm does not run the pre-rm hook for the main worktree" { + local branch; branch=$(git -C "$TEST_REPO" rev-parse --abbrev-ref HEAD) + mkdir -p "$TEST_REPO/.wt-hooks" + printf '#!/bin/sh\ntouch /tmp/wt-mainguard-ran\n' > "$TEST_REPO/.wt-hooks/pre-rm" + chmod +x "$TEST_REPO/.wt-hooks/pre-rm" + rm -f /tmp/wt-mainguard-ran + run wt rm "$branch" + local ran=0; [ -e /tmp/wt-mainguard-ran ] && ran=1 + rm -f /tmp/wt-mainguard-ran + [ "$status" -ne 0 ] + [ "$ran" -eq 0 ] +} + +@test "wt rm still removes a linked worktree from inside another worktree" { + cd "$TEST_REPO-feature" + run wt rm other + [ "$status" -eq 0 ] + [ ! -d "$TEST_REPO-other" ] +} + @test "wt rm returns error for no match" { run wt rm nonexistent [ "$status" -eq 1 ] diff --git a/wt.sh b/wt.sh index ff4f0de..c727728 100644 --- a/wt.sh +++ b/wt.sh @@ -99,8 +99,7 @@ _wt_claude_init() { _wt_claude_table() { # the main worktree's branch changes over time, so label it distinctly # rather than attributing sessions to whatever is checked out now - local main_wt - main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') + local main_wt; main_wt=$(_wt_root) # NB: never name a shell variable "path" - zsh ties it to $PATH local wt_path branch display_branch sessions rows @@ -244,6 +243,8 @@ _wt_rm() { local target target=$(_wt_resolve "${1?usage: wt rm [--claude] [--pre-hook P] [--post-hook P]}") [ -z "$target" ] && { echo "wt: no worktree matching '$1'" >&2; return 1; } + # git would refuse this anyway, but only after the pre-rm hook had already run + [ "$target" = "$root" ] && { echo "wt: refusing to remove the main worktree" >&2; return 1; } # preflight claude/jq before doing anything destructive if [ "$claude" -eq 1 ]; then _wt_claude_init @@ -344,7 +345,7 @@ _wt_merged() { # NB: never name a shell variable "path" - zsh ties it to $PATH, so a # `local path` (or a bare `read -r path`) wipes PATH for everything below local main_wt wt_path branch failed=0 - main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') + main_wt=$(_wt_root) while IFS=$'\t' read -r wt_path branch; do [ -z "$wt_path" ] && continue if [ "$wt_path" = "$main_wt" ]; then From c1b80ab621c3af7b69d47160101ab9304dadb2a5 Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Fri, 21 Aug 2026 15:48:49 +0100 Subject: [PATCH 3/7] feat(hooks): pass the main worktree root to hooks as WT_ROOT 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 --- README.md | 2 +- test/mk.bats | 14 ++++++++++++++ wt.sh | 9 +++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 12eb4c5..53fa9c5 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Place executable scripts in `.wt-hooks/` at your repo root to run custom | `pre-rm` | Before removing a worktree (non-zero exit aborts) | Worktree being removed | | `post-rm` | After removing a worktree | Original repo | -Each hook receives the branch name and path via env vars `WT_BRANCH` and `WT_PATH`. The standard `OLDPWD` is also available, pointing to the directory you were in before the worktree was created. +Each hook receives the branch name and path via env vars `WT_BRANCH` and `WT_PATH`, plus the main worktree's root as `WT_ROOT` (useful for calling a setup script that lives in the repo). The standard `OLDPWD` is also available, pointing to the directory you were in before the worktree was created. **Example** - copy env and install dependencies after creating a worktree: diff --git a/test/mk.bats b/test/mk.bats index 1a62818..d00db8b 100644 --- a/test/mk.bats +++ b/test/mk.bats @@ -109,6 +109,20 @@ teardown() { wt_common_teardown; } # --- hooks --- +@test "post-mk hook receives WT_ROOT pointing at the main worktree" { + local branch="hook-root" + local expected="$(wt_dest "$branch")" + mkdir -p "$TEST_REPO/.wt-hooks" + printf '#!/bin/sh\necho "$WT_ROOT" > /tmp/wt-hook-root\n' > "$TEST_REPO/.wt-hooks/post-mk" + chmod +x "$TEST_REPO/.wt-hooks/post-mk" + cd "$TEST_REPO-feature" + wt mk "$branch" + local out; out=$(cat /tmp/wt-hook-root); rm -f /tmp/wt-hook-root + cd "$TEST_REPO" + git worktree remove "$expected" + [ "$out" = "$TEST_REPO" ] +} + @test "post-mk hook is called with WT_BRANCH and WT_PATH" { local branch="hook-test" local expected="$(wt_dest "$branch")" diff --git a/wt.sh b/wt.sh index c727728..e67379d 100644 --- a/wt.sh +++ b/wt.sh @@ -159,7 +159,7 @@ _wt_run_hook() { local root="${_WT_HOOK_ROOT:-$(_wt_root)}" local hookfile="$root/.wt-hooks/$event" [ -x "$hookfile" ] || return 0 - WT_BRANCH="$1" WT_PATH="$2" "$hookfile" + WT_BRANCH="$1" WT_PATH="$2" WT_ROOT="$root" "$hookfile" } # run an ad-hoc hook script passed via --pre-hook / --post-hook @@ -167,10 +167,11 @@ _wt_run_adhoc_hook() { local file="$1" branch="$2" wt_path="$3" [ -n "$file" ] || return 0 [ -e "$file" ] || { echo "wt: hook file not found: $file" >&2; return 1; } + local root="${_WT_HOOK_ROOT:-$(_wt_root)}" if [ -x "$file" ]; then - WT_BRANCH="$branch" WT_PATH="$wt_path" "$file" + WT_BRANCH="$branch" WT_PATH="$wt_path" WT_ROOT="$root" "$file" else - WT_BRANCH="$branch" WT_PATH="$wt_path" bash "$file" + WT_BRANCH="$branch" WT_PATH="$wt_path" WT_ROOT="$root" bash "$file" fi } @@ -401,7 +402,7 @@ Options (rm): Hooks: Place executable scripts in .wt-hooks/ at the repo root. Events: pre-mk, post-mk, pre-rm, post-rm - Hook scripts receive WT_BRANCH and WT_PATH env vars. + Hook scripts receive WT_BRANCH, WT_PATH and WT_ROOT env vars. .worktreeinclude: List gitignored paths (gitignore syntax) at the repo root to copy From 662d2951876beb550718e6684037930ba52e1e52 Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Fri, 21 Aug 2026 23:21:18 +0100 Subject: [PATCH 4/7] docs: match the README's one-line-per-paragraph wrapping Co-Authored-By: Claude Opus 5 --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 53fa9c5..0f27994 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,9 @@ wt help # show usage Aliases: `add` → `mk`, `remove` → `rm`, `list` → `ls` -Worktrees are created in `.claude/worktrees/` inside the repo, the same place -Claude Code puts them, so both tools see the same set. Add `.claude/worktrees/` to your -`.gitignore` if it isn't already. Slashes in a branch name become dashes in the folder. +Worktrees are created in `.claude/worktrees/` inside the repo, the same place Claude Code puts them, so both tools see the same set. Add `.claude/worktrees/` to your `.gitignore` if it isn't already. Slashes in a branch name become dashes in the folder. -`wt mk` reuses an existing branch where there is one - a local branch is checked out as -is, and a branch that only exists on `origin` gets a local tracking branch. Otherwise the -branch is created, from `--base` if given. +`wt mk` reuses an existing branch where there is one - a local branch is checked out as is, and a branch that only exists on `origin` gets a local tracking branch. Otherwise the branch is created, from `--base` if given. Tab completion works for subcommands and branch names in both bash and zsh, matching anywhere in the branch name (`wt api-webhook` → `worktree-api-webhook-error-alerts`). From 6e2a0ee46f1fc3b4eb1ecc3cc3c3a7ad8fec470a Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Fri, 21 Aug 2026 23:24:56 +0100 Subject: [PATCH 5/7] fix: abort when the repo root can't be resolved _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 --- test/mk.bats | 10 ++++++++++ wt.sh | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/test/mk.bats b/test/mk.bats index d00db8b..84d5a60 100644 --- a/test/mk.bats +++ b/test/mk.bats @@ -101,6 +101,16 @@ teardown() { wt_common_teardown; } git worktree remove "$(wt_dest first)" } +@test "wt mk outside a repo fails without falling back to /" { + local outside; outside=$(mktemp -d) + cd "$outside" + run wt mk stray + [ "$status" -ne 0 ] + [ ! -d "$outside/.claude" ] + cd "$TEST_REPO" + rm -rf "$outside" +} + @test "wt mk errors on unknown flag" { run wt mk --bogus value branch [ "$status" -ne 0 ] diff --git a/wt.sh b/wt.sh index e67379d..35f97ca 100644 --- a/wt.sh +++ b/wt.sh @@ -205,7 +205,7 @@ _wt_mk() { done set -- "${args[@]}" local branch="${1?usage: wt mk [path] [--base B] [--pre-hook P] [--post-hook P]}" - local root; root=$(_wt_root) + local root; root=$(_wt_root) || return 1 local safe="${branch//\//-}" local dest="${2:-$root/.claude/worktrees/$safe}" _WT_HOOK_ROOT="$root" _wt_run_hook pre-mk "$branch" "$dest" || return @@ -240,7 +240,7 @@ _wt_rm() { esac done set -- "${args[@]}" - local root; root=$(_wt_root) + local root; root=$(_wt_root) || return 1 local target target=$(_wt_resolve "${1?usage: wt rm [--claude] [--pre-hook P] [--post-hook P]}") [ -z "$target" ] && { echo "wt: no worktree matching '$1'" >&2; return 1; } From 6cd260c2483f49836089bbe58bfdd801194b9731 Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Sun, 23 Aug 2026 07:31:34 +0100 Subject: [PATCH 6/7] feat(mk): configurable worktree location via wt.path .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 --- README.md | 7 ++++++ test/mk.bats | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wt.sh | 42 +++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f27994..7f61937 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ Aliases: `add` → `mk`, `remove` → `rm`, `list` → `ls` Worktrees are created in `.claude/worktrees/` inside the repo, the same place Claude Code puts them, so both tools see the same set. Add `.claude/worktrees/` to your `.gitignore` if it isn't already. Slashes in a branch name become dashes in the folder. +Set `wt.path` to put them somewhere else - `{name}` is the branch with slashes replaced, `{repo}` the repo's folder name, and a relative template resolves against the repo root so it means the same from any worktree: + +```sh +git config wt.path '../{repo}-{name}' # sibling of the repo +git config --global wt.path '~/wt/{name}' # all repos, outside the tree +``` + `wt mk` reuses an existing branch where there is one - a local branch is checked out as is, and a branch that only exists on `origin` gets a local tracking branch. Otherwise the branch is created, from `--base` if given. Tab completion works for subcommands and branch names in both bash and zsh, matching anywhere in the branch name (`wt api-webhook` → `worktree-api-webhook-error-alerts`). diff --git a/test/mk.bats b/test/mk.bats index 84d5a60..8b1aca5 100644 --- a/test/mk.bats +++ b/test/mk.bats @@ -111,6 +111,68 @@ teardown() { wt_common_teardown; } rm -rf "$outside" } +# --- wt.path --- + +@test "wt.path relative template resolves against the repo root" { + git config wt.path 'wts/{name}' + wt mk cfg-rel + [ -d "$TEST_REPO/wts/cfg-rel" ] + [ "$PWD" = "$TEST_REPO/wts/cfg-rel" ] + cd "$TEST_REPO" + git worktree remove "$TEST_REPO/wts/cfg-rel" +} + +@test "wt.path {repo} restores the old sibling layout" { + git config wt.path '../{repo}-{name}' + local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-cfg-sib" + wt mk cfg-sib + [ -d "$expected" ] + cd "$TEST_REPO" + git worktree remove "$expected" +} + +@test "wt.path absolute template is used as is" { + local outside; outside=$(mktemp -d) + git config wt.path "$outside/{name}" + wt mk cfg-abs + [ -d "$outside/cfg-abs" ] + cd "$TEST_REPO" + git worktree remove "$outside/cfg-abs" + rm -rf "$outside" +} + +@test "wt.path resolves the same from inside another worktree" { + git config wt.path 'wts/{name}' + cd "$TEST_REPO-feature" + wt mk cfg-fromwt + [ -d "$TEST_REPO/wts/cfg-fromwt" ] + cd "$TEST_REPO" + git worktree remove "$TEST_REPO/wts/cfg-fromwt" +} + +@test "wt.path without {name} is rejected" { + git config wt.path 'wts/fixed' + run wt mk cfg-bad + [ "$status" -ne 0 ] + [[ "$output" == *"must contain {name}"* ]] + [ ! -d "$TEST_REPO/wts/fixed" ] +} + +@test "wt mk warns when the worktree dir is not gitignored" { + run wt mk cfg-warn + [[ "$output" == *"not gitignored"* ]] + cd "$TEST_REPO" + git worktree remove "$(wt_dest cfg-warn)" +} + +@test "wt mk does not warn when the worktree dir is gitignored" { + echo ".claude/worktrees/" > "$TEST_REPO/.gitignore" + run wt mk cfg-nowarn + [[ "$output" != *"not gitignored"* ]] + cd "$TEST_REPO" + git worktree remove "$(wt_dest cfg-nowarn)" +} + @test "wt mk errors on unknown flag" { run wt mk --bogus value branch [ "$status" -ne 0 ] diff --git a/wt.sh b/wt.sh index 35f97ca..1487b4b 100644 --- a/wt.sh +++ b/wt.sh @@ -189,7 +189,36 @@ _wt_copy_worktreeinclude() { done } -# create a new worktree under .claude/worktrees/ in the repo (optional explicit path as second arg) +# where a new worktree goes: the wt.path template if set, else .claude/worktrees/. +# {name} is the branch with slashes replaced, {repo} the repo's folder name. A relative +# template resolves against the repo root, so it means the same from any worktree. +_wt_dest_default() { + local root="$1" safe="$2" tmpl + tmpl=$(git -C "$root" config wt.path) || tmpl='.claude/worktrees/{name}' + case "$tmpl" in + *'{name}'*) ;; + *) echo "wt: wt.path must contain {name}, got '$tmpl'" >&2; return 1 ;; + esac + tmpl=${tmpl//\{name\}/$safe} + tmpl=${tmpl//\{repo\}/$(basename "$root")} + case "$tmpl" in + "~/"*) printf '%s' "$HOME/${tmpl#\~/}" ;; + /*) printf '%s' "$tmpl" ;; + *) printf '%s/%s' "$root" "$tmpl" ;; + esac +} + +# warn when a worktree inside the repo isn't gitignored, so it doesn't show up as +# untracked in every git status from now on +_wt_warn_unignored() { + local root="$1" dest="$2" + case "$dest" in "$root"/*) ;; *) return 0 ;; esac + local rel="${dest#"$root"/}" + git -C "$root" check-ignore -q "$rel" && return 0 + echo "wt: $rel is not gitignored; add it to .gitignore to keep git status clean" >&2 +} + +# create a new worktree (optional explicit path as second arg) _wt_mk() { local pre_hook="" post_hook="" base="" local -a args @@ -207,7 +236,9 @@ _wt_mk() { local branch="${1?usage: wt mk [path] [--base B] [--pre-hook P] [--post-hook P]}" local root; root=$(_wt_root) || return 1 local safe="${branch//\//-}" - local dest="${2:-$root/.claude/worktrees/$safe}" + local dest="$2" + [ -n "$dest" ] || { dest=$(_wt_dest_default "$root" "$safe") || return 1; } + _wt_warn_unignored "$root" "$dest" _WT_HOOK_ROOT="$root" _wt_run_hook pre-mk "$branch" "$dest" || return _wt_run_adhoc_hook "$pre_hook" "$branch" "$dest" || return if [ -n "$base" ]; then @@ -404,6 +435,13 @@ Hooks: Events: pre-mk, post-mk, pre-rm, post-rm Hook scripts receive WT_BRANCH, WT_PATH and WT_ROOT env vars. +wt.path: + Where `wt mk` puts a worktree, if you don't want .claude/worktrees/: + git config wt.path '../{repo}-{name}' # sibling of the repo + git config --global wt.path '~/wt/{name}' # all repos, outside the tree + {name} is the branch with slashes replaced, {repo} the repo's folder name. + A relative template resolves against the repo root. + .worktreeinclude: List gitignored paths (gitignore syntax) at the repo root to copy them into each new worktree. Compatible with Claude Code. From 336319ea1486c0c9c2c9574eb07f174c7f84239b Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Sun, 23 Aug 2026 07:38:42 +0100 Subject: [PATCH 7/7] fix(mk): don't warn about gitignore for worktrees outside the repo 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 --- test/mk.bats | 7 +++++-- wt.sh | 25 ++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/test/mk.bats b/test/mk.bats index 8b1aca5..902d939 100644 --- a/test/mk.bats +++ b/test/mk.bats @@ -125,8 +125,10 @@ teardown() { wt_common_teardown; } @test "wt.path {repo} restores the old sibling layout" { git config wt.path '../{repo}-{name}' local expected="$(dirname "$TEST_REPO")/$(basename "$TEST_REPO")-cfg-sib" - wt mk cfg-sib + run wt mk cfg-sib [ -d "$expected" ] + [[ "$output" != *"not gitignored"* ]] + [[ "$output" != *"outside repository"* ]] cd "$TEST_REPO" git worktree remove "$expected" } @@ -134,8 +136,9 @@ teardown() { wt_common_teardown; } @test "wt.path absolute template is used as is" { local outside; outside=$(mktemp -d) git config wt.path "$outside/{name}" - wt mk cfg-abs + run wt mk cfg-abs [ -d "$outside/cfg-abs" ] + [[ "$output" != *"not gitignored"* ]] cd "$TEST_REPO" git worktree remove "$outside/cfg-abs" rm -rf "$outside" diff --git a/wt.sh b/wt.sh index 1487b4b..504b050 100644 --- a/wt.sh +++ b/wt.sh @@ -208,13 +208,32 @@ _wt_dest_default() { esac } +# collapse . and .. in a path textually - the target may not exist yet, so this can't +# go via realpath/cd. Avoids IFS word splitting, which zsh doesn't do by default. +_wt_normpath() { + local rest="$1" out="" seg lead="" + case "$rest" in /*) lead="/" ;; esac + while [ -n "$rest" ]; do + seg="${rest%%/*}" + if [ "$seg" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi + case "$seg" in + ''|.) ;; + ..) out="${out%/*}" ;; + *) out="$out/$seg" ;; + esac + done + printf '%s' "$lead${out#/}" +} + # warn when a worktree inside the repo isn't gitignored, so it doesn't show up as # untracked in every git status from now on _wt_warn_unignored() { - local root="$1" dest="$2" + local root="$1" dest; dest=$(_wt_normpath "$2") case "$dest" in "$root"/*) ;; *) return 0 ;; esac - local rel="${dest#"$root"/}" - git -C "$root" check-ignore -q "$rel" && return 0 + local rel="${dest#"$root"/}" rc=0 + git -C "$root" check-ignore -q "$rel" || rc=$? + # 0 ignored, 1 not ignored, anything else is an error we shouldn't report as "not ignored" + [ "$rc" -eq 1 ] || return 0 echo "wt: $rel is not gitignored; add it to .gitignore to keep git status clean" >&2 }