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" +} + +@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" + 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/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 cb591ff..4b21a75 100644 --- a/wt.sh +++ b/wt.sh @@ -120,6 +120,25 @@ _wt_claude_table() { fi } +# 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" 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 <<< "$ids" + return "$rc" +} + # navigate to a worktree by branch name or directory basename _wt_cd() { local target @@ -197,12 +216,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 +231,28 @@ _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; } + # 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" - git worktree remove "$target" + local rc=0 + 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 ]; then + _wt_claude_rm_sessions "$target" || rc=1 + fi + return "$rc" } # prune stale worktree refs @@ -229,10 +262,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 +303,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 failed=0 + 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" || failed=1 + else + _wt_rm "$branch" || failed=1 + fi + done <<< "$list" + [ "$failed" -eq 0 ] } # show usage information @@ -300,12 +368,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