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
7 changes: 5 additions & 2 deletions bin/README-tree-me.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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:

Expand All @@ -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
Expand Down
21 changes: 7 additions & 14 deletions bin/lib/git-worktree.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,14 @@ worktree_path_for() {
'
}

# Print "<branch>\t<path>" 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 "<kind>\t<name>\t<path>" 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 }
'
}

Expand Down
93 changes: 93 additions & 0 deletions bin/lib/test-tree-me-remove.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> at $WT_BASE/<branch> and echoes its path.
mkworktree() {
Expand All @@ -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.
Expand All @@ -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)
Expand Down
105 changes: 77 additions & 28 deletions bin/tree-me
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@ Commands:
create <branch> [base] Create new branch in worktree (default: main/master)
pr <number|url> Checkout GitHub PR in worktree (uses gh)
list, ls List all worktrees
remove, rm [-f] <branch|pat> Remove a worktree (quote patterns: "review-*")
remove, rm [-f] <ref|pattern> 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:
Expand All @@ -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-*"
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -416,7 +430,7 @@ EOF
--) shift; args+=("$@"); break ;;
-*)
echo "Error: Unknown option '$1'" >&2
echo "Usage: tree-me remove [-f] <branch|pattern>" >&2
echo "Usage: tree-me remove [-f] <branch|sha|pattern>" >&2
exit 1
;;
*) args+=("$1") ;;
Expand All @@ -425,12 +439,12 @@ EOF
done

if [ ${#args[@]} -eq 0 ]; then
echo "Error: Branch name or pattern required. Usage: tree-me remove [-f] <branch|pattern>" >&2
echo "Error: Worktree reference or pattern required. Usage: tree-me remove [-f] <branch|sha|pattern>" >&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] <branch|pattern>" >&2
echo "Usage: tree-me remove [-f] <branch|sha|pattern>" >&2
exit 1
fi
pattern="${args[0]}"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading