Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Then reload your shell (`source ~/.zshrc`) or open a new terminal.
```sh
wt # list all worktrees
wt <name> # cd into worktree by branch name
wt mk <branch> # create worktree as sibling of current repo and cd into it
wt mk <branch> # create worktree in .claude/worktrees/<branch> and cd into it
wt mk <branch> <path> # create worktree at a specific path and cd into it
wt rm <name> # remove a worktree
wt prune # prune stale worktree refs
Expand All @@ -39,6 +39,17 @@ wt help # show usage

Aliases: `add` → `mk`, `remove` → `rm`, `list` → `ls`

Worktrees are created in `.claude/worktrees/<branch>` 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<TAB>` → `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 <name>` yourself to remove them. (`wt prune` is unrelated: it just cleans up `git worktree` metadata for directories that were deleted outside of `wt rm`.)
Expand Down Expand Up @@ -72,7 +83,7 @@ Place executable scripts in `.wt-hooks/<event>` 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:

Expand Down
6 changes: 6 additions & 0 deletions test/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
171 changes: 153 additions & 18 deletions test/mk.bats
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,33 @@ 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"
}

@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"
}

@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"
}

@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"
Expand All @@ -47,14 +47,135 @@ 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)
[ "$new_commit" = "$base_commit" ]
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 ]
Expand All @@ -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"
Expand All @@ -77,15 +212,15 @@ 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"
}

@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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"* ]]
Expand Down
28 changes: 28 additions & 0 deletions test/rm.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand Down
Loading