From a2b09ab81ea79c51ef270c70e5c0eef19791891f Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Thu, 6 Aug 2026 19:20:43 +0100 Subject: [PATCH 1/2] feat(rm): wt rm --claude and wt merged --rm to clean up worktrees + sessions - `wt rm --claude` removes the worktree and then deletes its Claude Code sessions via the official `claude rm ` (sessions are matched with the same job-state-enriched attribution as the tables, and only deleted after the worktree removal actually succeeded). - `wt merged --rm [base]` bulk-removes everything `wt merged` lists, showing the list and asking for confirmation first (-y/--yes to skip); with --claude it deletes each worktree's sessions too. - The main working tree is never removed, even when it happens to be checked out on a merged branch - it's skipped with a warning. - Each removal goes through _wt_rm, so pre-rm/post-rm hooks fire per worktree as usual; _wt_rm now also propagates git worktree remove's exit code instead of swallowing it. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++ test/claude/merged.bats | 32 +++++++++++++++ test/claude/rm.bats | 88 ++++++++++++++++++++++++++++++++++++++++ test/merged.bats | 53 ++++++++++++++++++++++++ wt.sh | 89 ++++++++++++++++++++++++++++++++++++----- 5 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 test/claude/rm.bats diff --git a/README.md b/README.md index aa57aa0..b0f8a3c 100644 --- a/README.md +++ b/README.md @@ -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 --claude # remove a worktree and delete its Claude Code sessions wt cd # explicit cd (same as wt ) wt help # show usage ``` @@ -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 --claude` removes the worktree and deletes its sessions. + Requires `jq`. ## Hooks diff --git a/test/claude/merged.bats b/test/claude/merged.bats index cfe03d2..a702de7 100644 --- a/test/claude/merged.bats +++ b/test/claude/merged.bats @@ -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" <> "$stubbin/rm.log" + exit 0 +fi +cat < "$stubbin/jq" <` calls, and report one session for the + # feature worktree from `agents --json --all` + cat > "$stubbin/claude" <> "$stubbin/rm.log" + exit 0 +fi +cat < "$stubbin/jq" < "$stubbin/claude" <> "$stubbin/rm.log" + exit 0 +fi +echo '[]' +EOF + chmod +x "$stubbin/claude" + cat > "$stubbin/jq" < "$stubbin/claude" <> "$stubbin/rm.log" + exit 0 +fi +cat < "$stubbin/jq" < "$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" +} diff --git a/test/merged.bats b/test/merged.bats index 7c52683..1411511 100644 --- a/test/merged.bats +++ b/test/merged.bats @@ -75,3 +75,56 @@ 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 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" ] +} diff --git a/wt.sh b/wt.sh index cb591ff..d448116 100644 --- a/wt.sh +++ b/wt.sh @@ -120,6 +120,26 @@ _wt_claude_table() { fi } +# delete the Claude Code sessions recorded against a worktree path (claude rm) +_wt_claude_rm_sessions() { + local path="$1" + _wt_claude_init + case $? in + 1) return 0 ;; # no claude CLI - nothing to delete + 2) return 1 ;; + esac + local id + printf '%s' "$_WT_CLAUDE_JSON" | "$_WT_JQ_BIN" -r --arg wt "$path" \ + '.[] | select(.cwd == $wt) | .id' | while IFS= read -r id; do + [ -z "$id" ] && continue + if "$_WT_CLAUDE_BIN" rm "$id" >/dev/null 2>&1; then + echo "wt: deleted Claude session $id" + else + echo "wt: failed to delete Claude session $id" >&2 + fi + done +} + # navigate to a worktree by branch name or directory basename _wt_cd() { local target @@ -197,12 +217,13 @@ _wt_mk() { # remove a worktree by branch name or directory basename _wt_rm() { - local pre_hook="" post_hook="" + local pre_hook="" post_hook="" claude=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 ;; --*) echo "wt: unknown flag '$1'" >&2; return 1 ;; *) args+=("$1"); shift ;; @@ -211,15 +232,20 @@ _wt_rm() { set -- "${args[@]}" local root; root=$(git rev-parse --show-toplevel) local target - target=$(_wt_resolve "${1?usage: wt rm [--pre-hook P] [--post-hook P]}") + 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; } 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; } cd "$root" - git worktree remove "$target" + local rc=0 + git worktree remove "$target" || rc=$? _WT_HOOK_ROOT="$root" _wt_run_hook post-rm "$1" "$target" _wt_run_adhoc_hook "$post_hook" "$1" "$target" + if [ "$claude" -eq 1 ] && [ "$rc" -eq 0 ]; then + _wt_claude_rm_sessions "$target" + fi + return "$rc" } # prune stale worktree refs @@ -229,10 +255,12 @@ _wt_prune() { # list worktrees whose branch is already merged into main/master (candidates for removal) _wt_merged() { - local base="" show_claude=0 + local base="" show_claude=0 do_rm=0 assume_yes=0 while [ $# -gt 0 ]; do case "$1" in --claude) show_claude=1; shift ;; + --rm) do_rm=1; shift ;; + -y|--yes) assume_yes=1; shift ;; --*) echo "wt: unknown flag '$1'" >&2; return 1 ;; *) base="$1"; shift ;; esac @@ -268,15 +296,48 @@ _wt_merged() { if [ "$show_claude" -eq 0 ]; then printf '%s\n' "$list" | awk -F'\t' '{ print $1 " [" $2 "]" }' - return 0 + else + _wt_claude_init + case $? in + 1) show_claude=0 ;; # claude CLI missing (warned) - continue without it + 2) printf '%s\n' "$list" | awk -F'\t' '{ print $1 " [" $2 "]" }'; return 1 ;; + esac + if [ "$show_claude" -eq 1 ]; then + printf '%s\n' "$list" | _wt_claude_table + else + printf '%s\n' "$list" | awk -F'\t' '{ print $1 " [" $2 "]" }' + fi + fi + [ "$do_rm" -eq 0 ] && return 0 + + local count; count=$(printf '%s\n' "$list" | grep -c .) + if [ "$assume_yes" -eq 0 ]; then + local suffix="" + [ "$show_claude" -eq 1 ] && suffix=" and their Claude Code sessions" + printf 'wt: remove %s worktree(s)%s? [y/N] ' "$count" "$suffix" + local ans; read -r ans + case "$ans" in + y|Y|yes|YES) ;; + *) echo "wt: aborted"; return 1 ;; + esac fi - _wt_claude_init - case $? in - 1) printf '%s\n' "$list" | awk -F'\t' '{ print $1 " [" $2 "]" }'; return 0 ;; - 2) printf '%s\n' "$list" | awk -F'\t' '{ print $1 " [" $2 "]" }'; return 1 ;; - esac - printf '%s\n' "$list" | _wt_claude_table + # never remove the main working tree, even if it's on a merged branch + local main_wt path branch + main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') + while IFS=$'\t' read -r path branch; do + [ -z "$path" ] && continue + if [ "$path" = "$main_wt" ]; then + echo "wt: skipping main worktree [$branch]" >&2 + continue + fi + if [ "$show_claude" -eq 1 ]; then + _wt_rm --claude "$branch" + else + _wt_rm "$branch" + fi + done <<< "$list" + return 0 } # show usage information @@ -300,12 +361,18 @@ Aliases: add=mk, remove=rm, list=ls Options (ls|merged): --claude show a table of Claude Code agent sessions per worktree +Options (merged): + --rm remove the listed worktrees; with --claude, also delete + their Claude Code sessions + -y, --yes skip the confirmation prompt + Options (mk): --base BRANCH create the new branch from this commit-ish (default: HEAD) --pre-hook PATH run a script before the action (non-zero exit aborts) --post-hook PATH run a script after the action Options (rm): + --claude also delete the worktree's Claude Code sessions --pre-hook PATH run a script before the action (non-zero exit aborts) --post-hook PATH run a script after the action From 1502687849513b02ea872b056a7230b6dd982409 Mon Sep 17 00:00:00 2001 From: Andrew Stilliard Date: Thu, 6 Aug 2026 23:45:12 +0100 Subject: [PATCH 2/2] fix: address Copilot review feedback on rm support - post-rm hooks (and session deletion) no longer run when the worktree removal itself failed - _wt_rm returns the failure immediately - wt rm --claude preflights claude/jq before doing anything destructive: missing jq aborts with the worktree untouched, missing claude warns and proceeds as a plain rm - session deletion failures now propagate into wt rm's exit code (loop restructured out of the pipeline subshell so rc survives) - wt merged --rm tracks per-worktree failures and returns non-zero if any removal failed, instead of always reporting success - jq filter emits `.id // empty` so a session without an id can never produce a literal `claude rm null` Co-Authored-By: Claude Fable 5 --- test/claude/rm.bats | 47 +++++++++++++++++++++++++++++++++++++++++++++ test/merged.bats | 13 +++++++++++++ test/rm.bats | 13 +++++++++++++ wt.sh | 43 ++++++++++++++++++++++++----------------- 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/test/claude/rm.bats b/test/claude/rm.bats index 7b2ab31..b18174a 100644 --- a/test/claude/rm.bats +++ b/test/claude/rm.bats @@ -86,3 +86,50 @@ EOF [ ! -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" <> "$stubbin/rm.log" + exit 0 +fi +cat < "$stubbin/jq" < "$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" diff --git a/test/rm.bats b/test/rm.bats index 63e47b7..a2b946e 100644 --- a/test/rm.bats +++ b/test/rm.bats @@ -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" diff --git a/wt.sh b/wt.sh index d448116..4b21a75 100644 --- a/wt.sh +++ b/wt.sh @@ -120,24 +120,23 @@ _wt_claude_table() { fi } -# delete the Claude Code sessions recorded against a worktree path (claude rm) +# delete the Claude Code sessions recorded against a worktree path (claude rm); +# expects _wt_claude_init to have been run already _wt_claude_rm_sessions() { - local path="$1" - _wt_claude_init - case $? in - 1) return 0 ;; # no claude CLI - nothing to delete - 2) return 1 ;; - esac - local id - printf '%s' "$_WT_CLAUDE_JSON" | "$_WT_JQ_BIN" -r --arg wt "$path" \ - '.[] | select(.cwd == $wt) | .id' | while IFS= read -r id; do + local path="$1" rc=0 ids id + ids=$(printf '%s' "$_WT_CLAUDE_JSON" | "$_WT_JQ_BIN" -r --arg wt "$path" \ + '.[] | select(.cwd == $wt) | .id // empty') + [ -z "$ids" ] && return 0 + while IFS= read -r id; do [ -z "$id" ] && continue if "$_WT_CLAUDE_BIN" rm "$id" >/dev/null 2>&1; then echo "wt: deleted Claude session $id" else echo "wt: failed to delete Claude session $id" >&2 + rc=1 fi - done + done <<< "$ids" + return "$rc" } # navigate to a worktree by branch name or directory basename @@ -234,16 +233,24 @@ _wt_rm() { 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; } + # preflight claude/jq before doing anything destructive + if [ "$claude" -eq 1 ]; then + _wt_claude_init + case $? in + 1) claude=0 ;; # claude CLI missing (warned) - proceed without it + 2) return 1 ;; + esac + fi 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; } cd "$root" local rc=0 - git worktree remove "$target" || rc=$? + git worktree remove "$target" || return $? _WT_HOOK_ROOT="$root" _wt_run_hook post-rm "$1" "$target" _wt_run_adhoc_hook "$post_hook" "$1" "$target" - if [ "$claude" -eq 1 ] && [ "$rc" -eq 0 ]; then - _wt_claude_rm_sessions "$target" + if [ "$claude" -eq 1 ]; then + _wt_claude_rm_sessions "$target" || rc=1 fi return "$rc" } @@ -323,7 +330,7 @@ _wt_merged() { fi # never remove the main working tree, even if it's on a merged branch - local main_wt path branch + local main_wt path branch failed=0 main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') while IFS=$'\t' read -r path branch; do [ -z "$path" ] && continue @@ -332,12 +339,12 @@ _wt_merged() { continue fi if [ "$show_claude" -eq 1 ]; then - _wt_rm --claude "$branch" + _wt_rm --claude "$branch" || failed=1 else - _wt_rm "$branch" + _wt_rm "$branch" || failed=1 fi done <<< "$list" - return 0 + [ "$failed" -eq 0 ] } # show usage information