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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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 rm <name> -y # remove a worktree and its branch, no prompt
wt cd <name> # explicit cd (same as wt <name>)
wt help # show usage
```
Expand Down Expand Up @@ -71,6 +72,8 @@ Worktrees with no known session get a `-` placeholder row — handy for spotting

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.

Removing a worktree leaves its branch behind, so both commands then offer to delete the branches too (`wt rm` asks about the one branch, `wt merged --rm` asks once for the batch). Deletion always goes through `git branch -d`, never `-D`, so an unmerged branch is refused and reported rather than lost. `-y` answers yes to every prompt, worktrees and branches alike; with nothing on stdin to answer with the prompt goes unanswered and the branch is kept, so non-interactive callers are unaffected.

Requires `jq`.

## Hooks
Expand Down
5 changes: 3 additions & 2 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Commands:
wt cd <name> cd into worktree (explicit form)
wt ls [opts] list worktrees (same as bare wt)
wt mk <branch> [path] [opts] create worktree (default: .claude/worktrees/<branch>)
wt rm <name> [opts] remove a worktree
wt rm <name> [opts] remove a worktree, offering to delete its branch
wt prune prune stale worktree refs
wt merged [base] [opts] list worktrees merged into base (default: main/master)
wt help show this help
Expand All @@ -23,7 +23,7 @@ Options (ls|merged):
Options (merged):
--rm remove the listed worktrees; with --claude, also delete
their Claude Code sessions
-y, --yes skip the confirmation prompt
-y, --yes answer yes to the prompts (worktrees and their branches)

Options (mk):
--base BRANCH create the new branch from this commit-ish (default: HEAD)
Expand All @@ -33,6 +33,7 @@ Options (mk):

Options (rm):
--claude also delete the worktree's Claude Code sessions
-y, --yes answer yes to the "also delete branch?" prompt
--pre-hook PATH run a script before the action (non-zero exit aborts)
--post-hook PATH run a script after the action

Expand Down
34 changes: 31 additions & 3 deletions lib/commands/merged.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,47 @@ _wt_merged() {
# never remove the main working tree, even if it's on a merged branch
# 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
local main_wt wt_path branch failed=0 removed=""
main_wt=$(_wt_root)
while IFS=$'\t' read -r wt_path branch; do
[ -z "$wt_path" ] && continue
if [ "$wt_path" = "$main_wt" ]; then
echo "wt: skipping main worktree [$branch]" >&2
continue
fi
# branches are cleaned up in one batch below, so don't let _wt_rm ask per worktree
if [ "$show_claude" -eq 1 ]; then
_wt_rm --claude "$branch" || failed=1
_WT_SKIP_BRANCH_CLEANUP=1 _wt_rm --claude "$branch" || { failed=1; continue; }
else
_wt_rm "$branch" || failed=1
_WT_SKIP_BRANCH_CLEANUP=1 _wt_rm "$branch" || { failed=1; continue; }
fi
removed="$removed$branch
"
done <<< "$list"

_wt_merged_rm_branches "$removed" "$assume_yes" "$base"
[ "$failed" -eq 0 ]
}

# delete the branches of the worktrees just removed. They are all merged into the
# base by construction, so git branch -d accepts them; a refusal is git's own error
# on stderr and doesn't fail the command, same as the single-worktree path.
_wt_merged_rm_branches() {
local removed="$1" assume_yes="$2" base="$3" b
[ -n "$removed" ] || return 0
if [ "$assume_yes" -eq 0 ]; then
local count; count=$(printf '%s' "$removed" | grep -c .)
printf 'wt: also delete %s branch(es)? [y/N] ' "$count"
local ans=""; read -r ans || true # EOF (no answer piped in) means keep
case "$ans" in
y|Y|yes|YES) ;;
*) return 0 ;;
esac
fi
while IFS= read -r b; do
[ -n "$b" ] || continue
[ "$b" = "$base" ] && continue
_wt_del_branch "$b" || true
done <<< "$removed"
return 0
}
37 changes: 31 additions & 6 deletions lib/commands/rm.sh
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
# remove a worktree by branch name or directory basename
_wt_rm() {
local pre_hook="" post_hook="" claude=0
local pre_hook="" post_hook="" claude=0 assume_yes=0
local -a args
while [ $# -gt 0 ]; do
case "$1" in
--pre-hook) pre_hook="$2"; shift 2 ;;
--post-hook) post_hook="$2"; shift 2 ;;
--claude) claude=1; shift ;;
--) shift; args+=("$@"); break ;;
--pre-hook) pre_hook="$2"; shift 2 ;;
--post-hook) post_hook="$2"; shift 2 ;;
--claude) claude=1; shift ;;
-y|--yes) assume_yes=1; shift ;;
--) shift; args+=("$@"); break ;;
--*) echo "wt: unknown flag '$1'" >&2; return 1 ;;
*) args+=("$1"); shift ;;
esac
done
set -- "${args[@]}"
local root; root=$(_wt_root) || return 1
local target
target=$(_wt_resolve "${1?usage: wt rm <name> [--claude] [--pre-hook P] [--post-hook P]}")
target=$(_wt_resolve "${1?usage: wt rm <name> [--claude] [-y] [--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; }
Expand All @@ -27,6 +28,9 @@ _wt_rm() {
2) return 1 ;;
esac
fi
# the query may be a folder name, and after removal the worktree entry is gone,
# so read the branch off the worktree list while it still exists
local branch; branch=$(_wt_branch_of "$target")
cd "$target"
_WT_HOOK_ROOT="$root" _wt_run_hook pre-rm "$1" "$target" || { cd "$root"; return 1; }
_wt_run_adhoc_hook "$pre_hook" "$1" "$target" || { cd "$root"; return 1; }
Expand All @@ -38,5 +42,26 @@ _wt_rm() {
if [ "$claude" -eq 1 ]; then
_wt_claude_rm_sessions "$target" || rc=1
fi
_wt_rm_branch_cleanup "$branch" "$assume_yes"
return "$rc"
}

# offer to delete the branch a just-removed worktree was on. A detached HEAD means
# there is nothing to delete, and so does an unanswered prompt (read fails at EOF
# when nothing is piped in, leaving $ans empty -> keep the branch).
# git branch -d refuses unmerged branches; that failure is reported, not fatal.
_wt_rm_branch_cleanup() {
local branch="$1" assume_yes="$2"
[ -n "$branch" ] || return 0
# wt merged --rm cleans up its branches in one batch, so it suppresses this prompt
[ -n "$_WT_SKIP_BRANCH_CLEANUP" ] && return 0
if [ "$assume_yes" -eq 0 ]; then
printf "wt: also delete branch '%s'? [y/N] " "$branch"
local ans=""; read -r ans || true # EOF (no answer piped in) means keep
case "$ans" in
y|Y|yes|YES) ;;
*) return 0 ;;
esac
fi
_wt_del_branch "$branch" || true
}
15 changes: 15 additions & 0 deletions lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ _wt_resolve() {
'
}

# branch checked out in a given worktree path (empty for a detached HEAD)
_wt_branch_of() {
git worktree list --porcelain | awk -v q="$1" '
/^worktree / { wt = $2 }
/^branch / { branch = $2; sub("refs/heads/", "", branch) }
/^$/ { if (wt == q) { print branch; exit } wt = ""; branch = "" }
'
}
Comment thread
stilliard marked this conversation as resolved.

# delete a branch with git branch -d (never -D): git refuses unmerged branches and
# branches still checked out somewhere, which is the whole safety story here
_wt_del_branch() {
git branch -d "$1"
}

# list branch names for all worktrees
_wt_branches() {
git worktree list --porcelain 2>/dev/null | awk '
Expand Down
55 changes: 55 additions & 0 deletions test/merged.bats
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,58 @@ teardown() { wt_common_teardown; }
[ -d "$TEST_REPO" ]
[ ! -d "$TEST_REPO-feature" ]
}

# --- branch cleanup ---

@test "wt merged --rm asks once about the branches and deletes them on y" {
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
y"
[ "$status" -eq 0 ]
[[ "$output" == *"also delete 2 branch(es)?"* ]]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature && false || true
}

@test "wt merged --rm keeps the branches when the second prompt is declined" {
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
n"
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature
}

@test "wt merged --rm -y deletes the branches without asking" {
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 ]
[[ "$output" != *"also delete"* ]]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature && false || true
}

@test "wt merged --rm does not delete the branch of a worktree it failed to remove" {
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
echo "wip" > "$TEST_REPO-feature/untracked.txt"

run wt merged "$base" --rm -y
[ "$status" -ne 0 ]
[ -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature
}
64 changes: 64 additions & 0 deletions test/rm.bats
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,67 @@ teardown() { wt_common_teardown; }
[ "$status" -ne 0 ]
[ -d "$TEST_REPO-feature" ]
}

# --- branch cleanup ---

@test "wt rm asks about the branch and deletes it on y" {
cd "$TEST_REPO"
run wt rm feature <<< "y"
[ "$status" -eq 0 ]
[[ "$output" == *"also delete branch 'feature'?"* ]]
git show-ref --verify --quiet refs/heads/feature && false || true
}

@test "wt rm keeps the branch on anything but yes" {
cd "$TEST_REPO"
run wt rm feature <<< "n"
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature
}

@test "wt rm -y deletes the branch without asking" {
cd "$TEST_REPO"
run wt rm -y feature
[ "$status" -eq 0 ]
[[ "$output" != *"also delete branch"* ]]
git show-ref --verify --quiet refs/heads/feature && false || true
}

@test "wt rm keeps the branch when there is nothing on stdin to answer with" {
cd "$TEST_REPO"
run wt rm feature < /dev/null
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature
}

@test "wt rm -y refuses an unmerged branch but still succeeds" {
git -C "$TEST_REPO-feature" commit -q --allow-empty -m "unmerged work"
cd "$TEST_REPO"
run wt rm -y feature
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO-feature" ]
git show-ref --verify --quiet refs/heads/feature
[[ "$output" == *"not fully merged"* ]]
}

@test "wt rm -y by folder name deletes the worktree- prefixed branch" {
cd "$TEST_REPO"
wt mk worktree-branch-cleanup
cd "$TEST_REPO"
run wt rm -y branch-cleanup
[ "$status" -eq 0 ]
[ ! -d "$TEST_REPO/.claude/worktrees/branch-cleanup" ]
git show-ref --verify --quiet refs/heads/worktree-branch-cleanup && false || true
}

@test "wt rm skips branch cleanup for a detached HEAD worktree" {
cd "$TEST_REPO"
local det="$TEST_REPO-detached"
git worktree add -q --detach "$det"
run wt rm -y "$(basename "$det")"
[ "$status" -eq 0 ]
[[ "$output" != *"error"* ]]
rm -rf "$det"
}