From 154d38a2968a3e19504e07082e0da7dbb792a0af Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Thu, 27 Aug 2026 09:51:41 +0100 Subject: [PATCH 1/2] feat(rm): offer to delete the branch after removing a worktree `wt rm` and `wt merged --rm` left the branch behind, which piles up fast with Claude Code's worktree-* branches. Both now offer to clean them up: `wt rm` asks about its one branch, `wt merged --rm` asks once for the whole batch (and only for worktrees it actually removed). `-y` answers yes to every prompt, so `wt merged --rm -y` now deletes the merged branches too. Deletion always goes through `git branch -d`, never --force, so an unmerged branch is refused and reported without failing the command. With nothing on stdin to answer with, the prompt goes unanswered and the branch is kept, leaving non-interactive callers unaffected. Co-Authored-By: Claude Opus 5 --- README.md | 3 ++ lib/commands/help.sh | 5 ++-- lib/commands/merged.sh | 33 ++++++++++++++++++++-- lib/commands/rm.sh | 37 ++++++++++++++++++++---- lib/core.sh | 15 ++++++++++ test/merged.bats | 55 ++++++++++++++++++++++++++++++++++++ test/rm.bats | 64 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 31dc601..245c11b 100644 --- a/README.md +++ b/README.md @@ -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 --claude # remove a worktree and delete its Claude Code sessions +wt rm -y # remove a worktree and its branch, no prompt wt cd # explicit cd (same as wt ) wt help # show usage ``` @@ -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 --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 --force`, 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 diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 3492b3d..02d8369 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -10,7 +10,7 @@ Commands: wt cd cd into worktree (explicit form) wt ls [opts] list worktrees (same as bare wt) wt mk [path] [opts] create worktree (default: .claude/worktrees/) - wt rm [opts] remove a worktree + wt rm [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 @@ -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) @@ -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 diff --git a/lib/commands/merged.sh b/lib/commands/merged.sh index 01cf6a9..7431d13 100644 --- a/lib/commands/merged.sh +++ b/lib/commands/merged.sh @@ -70,7 +70,7 @@ _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 @@ -78,11 +78,38 @@ _wt_merged() { 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=1 [ "$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. +_wt_merged_rm_branches() { + local removed="$1" assume_yes="$2" base="$3" b rc=0 + [ -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" || rc=1 + done <<< "$removed" + return "$rc" +} diff --git a/lib/commands/rm.sh b/lib/commands/rm.sh index 89db418..0a7ae29 100644 --- a/lib/commands/rm.sh +++ b/lib/commands/rm.sh @@ -1,13 +1,14 @@ # 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 @@ -15,7 +16,7 @@ _wt_rm() { set -- "${args[@]}" local root; root=$(_wt_root) || return 1 local target - target=$(_wt_resolve "${1?usage: wt rm [--claude] [--pre-hook P] [--post-hook P]}") + target=$(_wt_resolve "${1?usage: wt rm [--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; } @@ -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; } @@ -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 +} diff --git a/lib/core.sh b/lib/core.sh index 1bda688..34f104b 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -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 = "" } + ' +} + +# 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 ' diff --git a/test/merged.bats b/test/merged.bats index 955a69b..d526930 100644 --- a/test/merged.bats +++ b/test/merged.bats @@ -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 +} diff --git a/test/rm.bats b/test/rm.bats index f86cb72..5eb744b 100644 --- a/test/rm.bats +++ b/test/rm.bats @@ -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" +} From e3128316291a99de1927894c6dd125b20985aa2c Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Thu, 27 Aug 2026 10:00:59 +0100 Subject: [PATCH 2/2] fix(merged): don't fail the command when a branch delete is refused Matches wt rm, where git's own error is reported but not fatal. Also corrects the README's name for git's forced delete flag (-D). Co-Authored-By: Claude Opus 5 --- README.md | 2 +- lib/commands/merged.sh | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 245c11b..91b7853 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ 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 --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 --force`, 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. +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`. diff --git a/lib/commands/merged.sh b/lib/commands/merged.sh index 7431d13..55fbedb 100644 --- a/lib/commands/merged.sh +++ b/lib/commands/merged.sh @@ -88,14 +88,15 @@ _wt_merged() { " done <<< "$list" - _wt_merged_rm_branches "$removed" "$assume_yes" "$base" || failed=1 + _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. +# 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 rc=0 + 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 .) @@ -109,7 +110,7 @@ _wt_merged_rm_branches() { while IFS= read -r b; do [ -n "$b" ] || continue [ "$b" = "$base" ] && continue - _wt_del_branch "$b" || rc=1 + _wt_del_branch "$b" || true done <<< "$removed" - return "$rc" + return 0 }