Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ wt merged # list worktrees whose branch is merged into main/mast
wt ls # list worktrees (same as bare wt)
wt ls --claude # list worktrees with their Claude Code agent sessions
wt merged --claude # merged-worktree candidates, with their Claude Code agent sessions
wt merged --rm # remove all merged worktrees (asks first; -y to skip)
wt rm <name> --claude # remove a worktree and delete its Claude Code sessions
wt cd <name> # explicit cd (same as wt <name>)
wt help # show usage
```
Expand All @@ -55,6 +57,8 @@ worktree-product-types-api - - -

Worktrees with no known session get a `-` placeholder row — handy for spotting merged branches that are safe to `wt rm`. Sessions that ran directly in your main repo checkout (rather than a dedicated worktree) are grouped under `(main, branch varies)`, since the main checkout's branch changes over time.

To clean up, `wt merged --rm` removes everything `wt merged` lists (never the main worktree), and adding `--claude` also deletes each worktree's Claude Code sessions via `claude rm`. It shows the list and asks for confirmation first — pass `-y` to skip. For a single worktree, `wt rm <name> --claude` removes the worktree and deletes its sessions.

Requires `jq`.

## Hooks
Expand Down
32 changes: 32 additions & 0 deletions test/claude/merged.bats
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,35 @@ EOF
[[ "$output" =~ feature.*-.*-.*- ]]
rm -rf "$stubbin"
}

@test "wt merged --rm --claude -y removes worktrees and deletes their sessions" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
cd "$TEST_REPO"
git merge -q feature

local stubbin; stubbin=$(mktemp -d)
cat > "$stubbin/claude" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "rm" ]; then
echo "\$2" >> "$stubbin/rm.log"
exit 0
fi
cat <<JSON
[{"id":"abc123","cwd":"$TEST_REPO-feature","name":"feature session","state":"done"}]
JSON
EOF
chmod +x "$stubbin/claude"
cat > "$stubbin/jq" <<EOF
#!/usr/bin/env bash
exec "$(command -v jq)" "\$@"
EOF
chmod +x "$stubbin/jq"

CLAUDE_CONFIG_DIR="$stubbin" PATH="$stubbin:$PATH" run wt merged "$base" --rm --claude -y
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
[[ "$output" == *"deleted Claude session abc123"* ]]
[ "$(cat "$stubbin/rm.log")" = "abc123" ]
rm -rf "$stubbin"
}
135 changes: 135 additions & 0 deletions test/claude/rm.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
load ../helpers

setup() { wt_common_setup; }
teardown() { wt_common_teardown; }

# --- wt rm --claude ---

@test "wt rm --claude removes the worktree and deletes its Claude sessions" {
local stubbin; stubbin=$(mktemp -d)
# fake claude: log `rm <id>` calls, and report one session for the
# feature worktree from `agents --json --all`
cat > "$stubbin/claude" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "rm" ]; then
echo "\$2" >> "$stubbin/rm.log"
exit 0
fi
cat <<JSON
[{"id":"abc123","cwd":"$TEST_REPO-feature","name":"feature session","state":"done"}]
JSON
EOF
chmod +x "$stubbin/claude"
cat > "$stubbin/jq" <<EOF
#!/usr/bin/env bash
exec "$(command -v jq)" "\$@"
EOF
chmod +x "$stubbin/jq"

CLAUDE_CONFIG_DIR="$stubbin" PATH="$stubbin:$PATH" run wt rm --claude feature
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
[[ "$output" == *"deleted Claude session abc123"* ]]
[ "$(cat "$stubbin/rm.log")" = "abc123" ]
rm -rf "$stubbin"
}

@test "wt rm --claude still removes a worktree that has no sessions" {
local stubbin; stubbin=$(mktemp -d)
cat > "$stubbin/claude" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "rm" ]; then
echo "\$2" >> "$stubbin/rm.log"
exit 0
fi
echo '[]'
EOF
chmod +x "$stubbin/claude"
cat > "$stubbin/jq" <<EOF
#!/usr/bin/env bash
exec "$(command -v jq)" "\$@"
EOF
chmod +x "$stubbin/jq"

CLAUDE_CONFIG_DIR="$stubbin" PATH="$stubbin:$PATH" run wt rm --claude feature
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
[ ! -e "$stubbin/rm.log" ]
rm -rf "$stubbin"
}

@test "wt rm --claude does not delete sessions when the worktree removal fails" {
local stubbin; stubbin=$(mktemp -d)
cat > "$stubbin/claude" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "rm" ]; then
echo "\$2" >> "$stubbin/rm.log"
exit 0
fi
cat <<JSON
[{"id":"abc123","cwd":"$TEST_REPO-feature","name":"feature session","state":"done"}]
JSON
EOF
chmod +x "$stubbin/claude"
cat > "$stubbin/jq" <<EOF
#!/usr/bin/env bash
exec "$(command -v jq)" "\$@"
EOF
chmod +x "$stubbin/jq"

# dirty worktree - git worktree remove refuses without --force
echo "wip" > "$TEST_REPO-feature/untracked.txt"

CLAUDE_CONFIG_DIR="$stubbin" PATH="$stubbin:$PATH" run wt rm --claude feature
[ "$status" -ne 0 ]
[ -d "$TEST_REPO-feature" ]
[ ! -e "$stubbin/rm.log" ]
rm -rf "$stubbin"
}

@test "wt rm --claude aborts before removal when jq is missing" {
local stubbin; stubbin=$(mktemp -d)
cat > "$stubbin/claude" <<'EOF'
#!/usr/bin/env bash
echo '[]'
EOF
chmod +x "$stubbin/claude"
# claude present but no jq - preflight must fail before anything is removed
ln -s "$(command -v git)" "$stubbin/git"
ln -s "$(command -v awk)" "$stubbin/awk"
ln -s "$(command -v dirname)" "$stubbin/dirname"

PATH="$stubbin" run wt rm --claude feature
[ "$status" -ne 0 ]
[[ "$output" == *"jq not found"* ]]
[ -d "$TEST_REPO-feature" ]
rm -rf "$stubbin"
}

@test "wt rm --claude never runs claude rm for sessions without an id" {
local stubbin; stubbin=$(mktemp -d)
# session matching the worktree but with no id field at all
cat > "$stubbin/claude" <<EOF
#!/usr/bin/env bash
if [ "\$1" = "rm" ]; then
echo "\$2" >> "$stubbin/rm.log"
exit 0
fi
cat <<JSON
[{"cwd":"$TEST_REPO-feature","name":"no id session","state":"done"}]
JSON
EOF
chmod +x "$stubbin/claude"
cat > "$stubbin/jq" <<EOF
#!/usr/bin/env bash
exec "$(command -v jq)" "\$@"
EOF
chmod +x "$stubbin/jq"

CLAUDE_CONFIG_DIR="$stubbin" PATH="$stubbin:$PATH" run wt rm --claude feature
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
[ ! -e "$stubbin/rm.log" ]
[[ "$output" != *"null"* ]]
rm -rf "$stubbin"
}
66 changes: 66 additions & 0 deletions test/merged.bats
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,69 @@ teardown() { wt_common_teardown; }
[ "$status" -ne 0 ]
[[ "$output" == *"unknown flag"* ]]
}

# --- --rm ---

@test "wt merged --rm -y removes merged worktrees and keeps unmerged ones" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
git -C "$TEST_REPO-other" commit -q --allow-empty -m "other commit"
cd "$TEST_REPO"
git merge -q feature

run wt merged "$base" --rm -y
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
[ -d "$TEST_REPO-other" ]
}

@test "wt merged --rm asks for confirmation and aborts on anything but yes" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
cd "$TEST_REPO"
git merge -q feature

run wt merged "$base" --rm <<< "n"
[ "$status" -ne 0 ]
[[ "$output" == *"aborted"* ]]
[ -d "$TEST_REPO-feature" ]
}

@test "wt merged --rm accepts y at the confirmation prompt" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
cd "$TEST_REPO"
git merge -q feature

run wt merged "$base" --rm <<< "y"
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
}

@test "wt merged --rm -y returns non-zero when a removal fails" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
cd "$TEST_REPO"
git merge -q feature
# dirty worktree - git worktree remove refuses without --force
echo "wip" > "$TEST_REPO-feature/untracked.txt"

run wt merged "$base" --rm -y
[ "$status" -ne 0 ]
[ -d "$TEST_REPO-feature" ]
}

@test "wt merged --rm never removes the main worktree" {
local base; base=$(git -C "$TEST_REPO" symbolic-ref --short HEAD)
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "feature commit"
cd "$TEST_REPO"
git merge -q feature
# main checkout sits on a merged, non-base branch - listed, but must be skipped
git checkout -qb main-drift

run wt merged "$base" --rm -y
[ "$status" -eq 0 ]
[[ "$output" == *"skipping main worktree"* ]]
[ -d "$TEST_REPO" ]
[ ! -d "$TEST_REPO-feature" ]
}
13 changes: 13 additions & 0 deletions test/rm.bats
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ teardown() { wt_common_teardown; }
[[ "$out" == "branch=feature path=$TEST_REPO-feature" ]]
}

@test "post-rm hook is not called when removal fails" {
mkdir -p "$TEST_REPO/.wt-hooks"
printf '#!/bin/sh\ntouch /tmp/wt-postrm-ran' > "$TEST_REPO/.wt-hooks/post-rm"
chmod +x "$TEST_REPO/.wt-hooks/post-rm"
rm -f /tmp/wt-postrm-ran
# dirty worktree - git worktree remove refuses without --force
echo "wip" > "$TEST_REPO-feature/untracked.txt"
run wt rm feature
[ "$status" -ne 0 ]
[ -d "$TEST_REPO-feature" ]
[ ! -e /tmp/wt-postrm-ran ]
}

@test "pre-rm hook failure aborts worktree removal" {
mkdir -p "$TEST_REPO/.wt-hooks"
printf '#!/bin/sh\nexit 1' > "$TEST_REPO/.wt-hooks/pre-rm"
Expand Down
Loading