diff --git a/README.md b/README.md index e458e33..7f61937 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,17 @@ 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. + +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`). `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`.) @@ -72,7 +83,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/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..902d939 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,127 @@ 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 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" +} + +# --- 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" + run wt mk cfg-sib + [ -d "$expected" ] + [[ "$output" != *"not gitignored"* ]] + [[ "$output" != *"outside repository"* ]] + 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}" + run wt mk cfg-abs + [ -d "$outside/cfg-abs" ] + [[ "$output" != *"not gitignored"* ]] + 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 ] @@ -63,9 +184,23 @@ 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="$(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 +212,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 +220,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 +233,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 +248,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 +263,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 +277,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 +288,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 +301,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 +314,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 +325,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 +337,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/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 864d55b..504b050 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" ' @@ -92,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 @@ -150,10 +156,10 @@ _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" + WT_BRANCH="$1" WT_PATH="$2" WT_ROOT="$root" "$hookfile" } # run an ad-hoc hook script passed via --pre-hook / --post-hook @@ -161,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 } @@ -182,7 +189,55 @@ _wt_copy_worktreeinclude() { done } -# create a new worktree as a sibling of the current 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 +} + +# 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; dest=$(_wt_normpath "$2") + case "$dest" in "$root"/*) ;; *) return 0 ;; esac + 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 +} + +# create a new worktree (optional explicit path as second arg) _wt_mk() { local pre_hook="" post_hook="" base="" local -a args @@ -198,13 +253,19 @@ _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) || return 1 local safe="${branch//\//-}" - local dest="${2:-$(dirname "$root")/$(basename "$root")-$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 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,10 +290,12 @@ _wt_rm() { esac done set -- "${args[@]}" - local root; root=$(git rev-parse --show-toplevel) + 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; } + # 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 @@ -333,7 +396,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 @@ -359,7 +422,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 +440,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 @@ -388,7 +452,14 @@ 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. + +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