From 01678dd2933a869a5794bfa415aeb9b6521c21af Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Tue, 1 Sep 2026 17:01:41 -0700 Subject: [PATCH] Support detached HEAD SHAs in tree-me remove --- bin/README-tree-me.md | 7 ++- bin/lib/git-worktree.sh | 21 +++---- bin/lib/test-tree-me-remove.sh | 93 +++++++++++++++++++++++++++++ bin/tree-me | 105 ++++++++++++++++++++++++--------- 4 files changed, 182 insertions(+), 44 deletions(-) diff --git a/bin/README-tree-me.md b/bin/README-tree-me.md index 9f2830c..fa45064 100644 --- a/bin/README-tree-me.md +++ b/bin/README-tree-me.md @@ -30,7 +30,7 @@ tree-me create feature-branch # Create new branch (auto-cd enabled) tree-me sw feature-branch # switch existing branch (alias for switch) tree-me pr 123 # Checkout GitHub PR (auto-cd enabled) tree-me ls # List all worktrees (alias for list) -tree-me rm feature-branch # Remove worktree (tab completion for branches) +tree-me rm feature-branch # Remove worktree (tab completion for branches and detached SHAs) tree-me prune # Clean up stale worktree files ``` @@ -94,12 +94,13 @@ Shows all worktrees with their paths and checked out branches. ```bash tree-me remove branch-name # Full command tree-me rm branch-name # Shorter alias (supports tab completion) +tree-me rm a1b2c3d # Remove a detached worktree by commit SHA tree-me rm -f branch-name # Force, flag position doesn't matter tree-me rm -f -f branch-name # Also removes a locked worktree tree-me rm -f "review-*" # Remove every match without confirming ``` -Removes the worktree for the specified branch. Use tab completion to see available branches. +Removes the worktree for the specified branch. A full or unique abbreviated commit SHA removes a detached worktree. Tab completion includes branches and detached HEAD SHAs. `-f` (or `--force`) is repeatable and passed straight through to `git worktree remove`, so git's own advice applies verbatim: @@ -111,6 +112,8 @@ Removes the worktree for the specified branch. Use tab completion to see availab For a pattern, `-f` also skips the "Remove N worktree(s)?" confirmation. Every match is attempted even if git refuses one of them; refused worktrees are listed as skipped and the command exits non-zero. +If several detached worktrees share the same commit, `tree-me` asks before removing all of them. Pass `-f` to skip that confirmation. + Use `--` if a branch name itself starts with a dash: `tree-me rm -- -weird-branch`. ### Clean up stale worktrees diff --git a/bin/lib/git-worktree.sh b/bin/lib/git-worktree.sh index 3e1207a..df4014c 100644 --- a/bin/lib/git-worktree.sh +++ b/bin/lib/git-worktree.sh @@ -17,21 +17,14 @@ worktree_path_for() { ' } -# Print "\t" for each non-main worktree, one per line. -# The main worktree (always first in `git worktree list`) is excluded. -# Detached and bare worktrees are skipped (they have no branch). -list_worktrees_excluding_main() { +# Print "\t\t" for non-main worktrees, using branch names +# when attached and HEAD SHAs when detached. +list_removable_worktrees() { git worktree list --porcelain | awk ' - function flush() { - if (branch != "") { - count++ - if (count > 1) printf "%s\t%s\n", branch, path - } - branch = ""; path = "" - } - /^worktree / { flush(); path = substr($0, 10) } - /^branch refs\/heads\// { branch = substr($0, 19) } - END { flush() } + /^worktree / { count++; path = substr($0, 10) } + /^HEAD / { head = substr($0, 6) } + count > 1 && /^branch refs\/heads\// { printf "branch\t%s\t%s\n", substr($0, 19), path } + count > 1 && /^detached$/ { printf "detached\t%s\t%s\n", head, path } ' } diff --git a/bin/lib/test-tree-me-remove.sh b/bin/lib/test-tree-me-remove.sh index 6f09995..6294719 100755 --- a/bin/lib/test-tree-me-remove.sh +++ b/bin/lib/test-tree-me-remove.sh @@ -37,6 +37,9 @@ git -C "$REPO_DIR" checkout -q -b main echo one > "$REPO_DIR/file" git -C "$REPO_DIR" add file git -C "$REPO_DIR" commit -qm "one" +FIRST_SHA=$(git -C "$REPO_DIR" rev-parse HEAD) +echo two > "$REPO_DIR/file" +git -C "$REPO_DIR" commit -qam "two" # Creates a worktree for at $WT_BASE/ and echoes its path. mkworktree() { @@ -45,6 +48,33 @@ mkworktree() { printf '%s\n' "$path" } +mkdetachedworktree() { + local path="$WT_BASE/$1" commit="${2:-main}" + git -C "$REPO_DIR" worktree add -q --detach "$path" "$commit" + printf '%s\n' "$path" +} + +zsh_complete() { + local subcommand="$1" prefix="$2" + TREE_ME_SUBCOMMAND="$subcommand" TREE_ME_PREFIX="$prefix" \ + REPO_DIR="$REPO_DIR" BIN="$BIN" zsh -c ' + compdef() { :; } + _describe() { + local candidate value prefix="$words[$CURRENT]" + for candidate in "${(@P)2}"; do + value="${candidate%%:*}" + [[ "$value" == "$prefix"* ]] && print -r -- "$value" + done + return 0 + } + source /dev/stdin <<< "$("$BIN" shellenv)" + cd "$REPO_DIR" + words=(tree-me "$TREE_ME_SUBCOMMAND" "$TREE_ME_PREFIX") + CURRENT=3 + _tree_me_complete_zsh + ' +} + # Runs tree-me inside the fixture repo, capturing combined output in $out and # the exit status in $rc. Stdin is /dev/null so an unexpected confirmation # prompt fails the run instead of hanging it. @@ -70,6 +100,69 @@ tree_me rm clean-one assert "removes a clean worktree without -f" test "$rc" -eq 0 assert "clean worktree directory is gone" test ! -d "$path" +mkworktree completion-branch >/dev/null +other_path=$(mkdetachedworktree detached-other-sha "$FIRST_SHA") +path=$(mkdetachedworktree detached-full-sha) +sha=$(git -C "$path" rev-parse HEAD) +: "${ZSH_VERSION:=}" +shellenv=$("$BIN" shellenv) +# shellcheck disable=SC1090 +source /dev/stdin <<< "$shellenv" +COMP_WORDS=(tree-me rm "${sha:0:7}") +COMP_CWORD=2 +cd "$REPO_DIR" +_tree_me_complete +assert "completion includes a detached worktree SHA" test "${COMPREPLY[*]}" = "$sha" + +COMP_WORDS=(tree-me sw "${sha:0:7}") +_tree_me_complete || true +assert "switch completion excludes detached worktree SHAs" test "${#COMPREPLY[@]}" -eq 0 + +COMP_WORDS=(tree-me rm completion) +_tree_me_complete +assert "remove completion includes attached branches" test "${COMPREPLY[*]}" = "completion-branch" + +COMP_WORDS=(tree-me sw completion) +_tree_me_complete +assert "switch completion includes attached branches" test "${COMPREPLY[*]}" = "completion-branch" +cd - >/dev/null + +zsh_rm=$(zsh_complete rm "${sha:0:7}") +assert "Zsh removal completion includes a detached worktree SHA" test "$zsh_rm" = "$sha" +zsh_sw=$(zsh_complete sw "${sha:0:7}") +assert "Zsh switch completion excludes detached worktree SHAs" test -z "$zsh_sw" + +tree_me rm "$sha" +assert "removes a detached worktree by full SHA" test "$rc" -eq 0 +assert "full SHA worktree directory is gone" test ! -d "$path" +assert "a detached worktree at another SHA survives" test -d "$other_path" + +path=$(mkdetachedworktree detached-short-sha) +sha=$(git -C "$path" rev-parse --short HEAD) +tree_me rm "$sha" +assert "removes a detached worktree by abbreviated SHA" test "$rc" -eq 0 +assert "abbreviated SHA worktree directory is gone" test ! -d "$path" + +path=$(mkdetachedworktree detached-hex-branch) +sha=$(git -C "$path" rev-parse --short HEAD) +git -C "$REPO_DIR" branch "$sha" main +tree_me rm "$sha" +assert "a hex branch name without a worktree is not treated as a SHA" test "$rc" -ne 0 +assert "the detached worktree survives a hex branch name" test -d "$path" +git -C "$REPO_DIR" branch -D "$sha" >/dev/null +tree_me rm "$sha" + +path_a=$(mkdetachedworktree detached-duplicate-a) +path_b=$(mkdetachedworktree detached-duplicate-b) +sha=$(git -C "$path_a" rev-parse HEAD) +tree_me rm "$sha" +assert "a SHA shared by detached worktrees prompts before removing all" test "$rc" -eq 0 +assert "an unconfirmed shared SHA leaves both worktrees" test -d "$path_a" -a -d "$path_b" + +tree_me rm -f "$sha" +assert "-f removes every detached worktree at a shared SHA" test "$rc" -eq 0 +assert "shared SHA worktree directories are gone" test ! -d "$path_a" -a ! -d "$path_b" + # ── Test: -f removes a worktree with uncommitted changes ─────────────────── path=$(mkworktree dirty-one) diff --git a/bin/tree-me b/bin/tree-me index 97ffa82..aefe397 100755 --- a/bin/tree-me +++ b/bin/tree-me @@ -46,14 +46,14 @@ Commands: create [base] Create new branch in worktree (default: main/master) pr Checkout GitHub PR in worktree (uses gh) list, ls List all worktrees - remove, rm [-f] Remove a worktree (quote patterns: "review-*") + remove, rm [-f] Remove by branch, detached HEAD SHA, or pattern prune Remove worktree administrative files shellenv Output shell function for auto-cd (source this) Options: -f, --force For remove: drop a worktree with uncommitted - changes, and remove every match of a pattern - without confirming. Repeat it (-f -f) to remove + changes, and skip confirmation for patterns or + shared SHAs. Repeat it (-f -f) to remove a locked worktree, as git itself requires. Examples: @@ -65,6 +65,7 @@ Examples: tree-me pr https://github.com/org/repo/pull/123 tree-me list tree-me remove old-branch + tree-me remove a1b2c3d tree-me remove -f old-branch tree-me remove -f -f locked-branch tree-me remove "review-*" @@ -259,6 +260,19 @@ tree-me() { return $exit_code } +_tree_me_worktree_candidates() { + local command="$1" include_detached=0 + case "$command" in + remove|rm) include_detached=1 ;; + esac + git worktree list --porcelain 2>/dev/null | awk -v include_detached="$include_detached" ' + /^worktree / { count++ } + /^HEAD / { head = substr($0, 6) } + count > 1 && /^branch refs\/heads\// { print substr($0, 19) } + count > 1 && include_detached && /^detached$/ { print head } + ' +} + # Bash completion if [ -n "$BASH_VERSION" ]; then _tree_me_complete() { @@ -279,7 +293,7 @@ if [ -n "$BASH_VERSION" ]; then case "${COMP_WORDS[1]}" in co|checkout|switch|sw|remove|rm) local candidates - candidates=$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +2) + candidates=$(_tree_me_worktree_candidates "${COMP_WORDS[1]}") # Offered only once a dash is typed, so a lone branch still # completes on its own rather than tying with the flag. case "${COMP_WORDS[1]}" in @@ -298,7 +312,7 @@ fi # Zsh completion if [ -n "$ZSH_VERSION" ]; then _tree_me_complete_zsh() { - local -a commands branches + local -a commands candidates commands=( 'switch:Switch to an existing branch in new worktree' 'sw:Switch to an existing branch in new worktree' @@ -320,16 +334,16 @@ if [ -n "$ZSH_VERSION" ]; then # does not turn completion off. case "$words[2]" in checkout|co|switch|sw|remove|rm) - branches=(${(f)"$(git worktree list 2>/dev/null | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | tail -n +2)"}) + candidates=(${(f)"$(_tree_me_worktree_candidates "$words[2]")"}) # Offered only once a dash is typed, so a lone branch still # completes on its own rather than tying with the flag. case "$words[2]" in remove|rm) [[ "$words[CURRENT]" == -* ]] && \ - branches+=('-f:Force removal; repeat for a locked worktree') + candidates+=('-f:Force removal; repeat for a locked worktree') ;; esac - _describe 'branch' branches + _describe 'worktree' candidates ;; esac fi @@ -416,7 +430,7 @@ EOF --) shift; args+=("$@"); break ;; -*) echo "Error: Unknown option '$1'" >&2 - echo "Usage: tree-me remove [-f] " >&2 + echo "Usage: tree-me remove [-f] " >&2 exit 1 ;; *) args+=("$1") ;; @@ -425,12 +439,12 @@ EOF done if [ ${#args[@]} -eq 0 ]; then - echo "Error: Branch name or pattern required. Usage: tree-me remove [-f] " >&2 + echo "Error: Worktree reference or pattern required. Usage: tree-me remove [-f] " >&2 exit 1 fi if [ ${#args[@]} -gt 1 ]; then echo "Error: Unexpected argument '${args[1]}' (quote patterns: \"review-*\")" >&2 - echo "Usage: tree-me remove [-f] " >&2 + echo "Usage: tree-me remove [-f] " >&2 exit 1 fi pattern="${args[0]}" @@ -439,35 +453,69 @@ EOF [ "$force" -ge 1 ] && force_args+=(--force) [ "$force" -ge 2 ] && force_args+=(--force) - declare -A branch_to_path=() - branches=() - while IFS=$'\t' read -r b p; do - branch_to_path[$b]=$p - branches+=("$b") - done < <(list_worktrees_excluding_main) + worktree_kinds=() + worktree_names=() + worktree_paths=() + while IFS=$'\t' read -r kind name path; do + worktree_kinds+=("$kind") + worktree_names+=("$name") + worktree_paths+=("$path") + done < <(list_removable_worktrees) is_glob=0 [[ "$pattern" == *[*?\[]* ]] && is_glob=1 matches=() - for b in "${branches[@]}"; do - # shellcheck disable=SC2053 - [[ "$b" == $pattern ]] && matches+=("$b") - done + if [ "$is_glob" = 1 ]; then + for i in "${!worktree_names[@]}"; do + # shellcheck disable=SC2053 + [[ "${worktree_names[$i]}" == $pattern ]] && matches+=("$i") + done + else + for i in "${!worktree_names[@]}"; do + if [ "${worktree_kinds[$i]}" = "branch" ] && [ "${worktree_names[$i]}" = "$pattern" ]; then + matches+=("$i") + fi + done + + if [ ${#matches[@]} -eq 0 ] && \ + [[ "$pattern" =~ ^[[:xdigit:]]{4,64}$ ]] && \ + ! git show-ref --verify --quiet "refs/heads/$pattern"; then + resolved_objects=() + while read -r object; do + [ -n "$object" ] && resolved_objects+=("$object") + done < <(git rev-parse --disambiguate="$pattern") + resolved_sha="" + if [ ${#resolved_objects[@]} -eq 1 ] && \ + [ "$(git cat-file -t "${resolved_objects[0]}" 2>/dev/null || true)" = "commit" ]; then + resolved_sha="${resolved_objects[0]}" + fi + if [ -n "$resolved_sha" ]; then + for i in "${!worktree_names[@]}"; do + if [ "${worktree_kinds[$i]}" = "detached" ] && [ "${worktree_names[$i]}" = "$resolved_sha" ]; then + matches+=("$i") + fi + done + fi + fi + fi if [ ${#matches[@]} -eq 0 ]; then if [ "$is_glob" = 1 ]; then echo "Error: No worktrees match pattern: $pattern" >&2 else - echo "Error: No worktree found for branch: $pattern" >&2 + echo "Error: No worktree found for branch or detached HEAD SHA: $pattern" >&2 fi exit 1 fi - if [ "$is_glob" = 1 ] && [ "$force" -eq 0 ]; then + confirm=0 + [ "$is_glob" = 1 ] && confirm=1 + [ ${#matches[@]} -gt 1 ] && confirm=1 + if [ "$confirm" = 1 ] && [ "$force" -eq 0 ]; then echo "Matching worktrees:" - for b in "${matches[@]}"; do - echo " $b → ${branch_to_path[$b]}" + for i in "${matches[@]}"; do + echo " ${worktree_names[$i]} → ${worktree_paths[$i]}" done # `read` reports EOF as a failure even when it read an answer that # simply lacked a trailing newline, so keep whatever it did read @@ -505,14 +553,15 @@ EOF } failed=0 - for b in "${matches[@]}"; do - path="${branch_to_path[$b]}" + for i in "${matches[@]}"; do + name="${worktree_names[$i]}" + path="${worktree_paths[$i]}" if output=$(git worktree remove "${force_args[@]}" "$path" 2>&1); then echo "✓ Removed worktree: $path" else echo "$output" >&2 if [ "$force" -lt 2 ] && echo "$output" | grep -q "locked working tree"; then - print_lock_hint "$output" "$b" + print_lock_hint "$output" "$name" fi echo "✗ Skipped worktree: $path" >&2 failed=1