From 2048510d910269f759f58244116722de11e70bb4 Mon Sep 17 00:00:00 2001
From: Kiko Beats
Date: Sun, 13 Sep 2026 07:22:58 +0000
Subject: [PATCH 1/2] feat: remove worktrees together with their garbage
branches
git branch -D fails with "cannot delete branch used by worktree" when a
garbage branch is still checked out in a linked worktree. Remove the clean
worktree first, then delete the branch. Branches whose worktree has
uncommitted changes, is locked, or is the main checkout are listed as
skipped and left alone.
Adds test.sh covering both paths plus locked, stale and main-checkout
worktrees; the new assertions fail against the previous garbage.sh.
Co-Authored-By: Claude Opus 5 (1M context)
---
README.md | 2 +
garbage.sh | 59 +++++++++++++++++++++--
package.json | 2 +-
test.sh | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 192 insertions(+), 5 deletions(-)
create mode 100755 test.sh
diff --git a/README.md b/README.md
index 9f71fcd..8abc385 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ npm install --global git-garbage
+A branch checked out in a linked worktree is removed together with its worktree. It is skipped when that worktree has uncommitted changes, is locked, or is the main checkout.
+
## Related
* [git-dirty](https://github.com/Kikobeats/git-dirty#git-dirty) – Ensures the working directory is clean and that there are no unpulled changes.
diff --git a/garbage.sh b/garbage.sh
index eee256d..a242016 100755
--- a/garbage.sh
+++ b/garbage.sh
@@ -10,10 +10,22 @@ if ! git fetch --prune --quiet 2> /dev/null; then
exit 1;
fi
+git worktree prune
+
current="$(git rev-parse --abbrev-ref HEAD)"
+worktree_list="$(git worktree list --porcelain)"
+main_worktree="$(printf '%s\n' "$worktree_list" | awk 'NR == 1 { print substr($0, 10) }')"
declare -a branches
+# Print the path of the worktree that has the branch checked out, if any
+worktree_of() {
+ printf '%s\n' "$worktree_list" | awk -v ref="refs/heads/$1" '
+ /^worktree / { path = substr($0, 10) }
+ $0 == "branch " ref { print path; exit }
+ '
+}
+
# Check if a branch was part of remote workflow (pushed at some point)
was_pushed() {
local branch="$1"
@@ -83,19 +95,58 @@ if command -v gh &> /dev/null && gh auth status &> /dev/null; then
fi
fi
-if [[ ${#branches[@]} -eq 0 ]]; then
+# branches checked out in another worktree go with their worktree, unless it has work in it
+declare -a removable worktrees skipped
+for branch in "${branches[@]}"; do
+ worktree="$(worktree_of "$branch")"
+ if [[ -z "$worktree" ]]; then
+ removable+=("$branch")
+ worktrees+=("")
+ elif [[ "$worktree" == "$main_worktree" ]]; then
+ skipped+=("$branch (checked out in main worktree $worktree)")
+ elif [[ -n "$(git -C "$worktree" status --porcelain 2> /dev/null)" ]]; then
+ skipped+=("$branch (uncommitted changes in worktree $worktree)")
+ else
+ removable+=("$branch")
+ worktrees+=("$worktree")
+ fi
+done
+
+if [[ ${#skipped[@]} -gt 0 ]]; then
+ echo
+ printf ' skipped %s\n' "${skipped[@]}"
+fi
+
+if [[ ${#removable[@]} -eq 0 ]]; then
printf "\n Nothing to garbage."
exit
fi
echo
-printf ' %s\n' "${branches[@]}"
+for i in "${!removable[@]}"; do
+ if [[ -n "${worktrees[$i]}" ]]; then
+ printf ' %s (and worktree %s)\n' "${removable[$i]}" "${worktrees[$i]}"
+ else
+ printf ' %s\n' "${removable[$i]}"
+ fi
+done
echo
read -rp " Will be removed. Continue? (y/N) " -n 1
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo && echo
- message=$(git branch -D "${branches[@]}")
- echo "${message//Deleted/ Deleted}"
+ declare -a deletable
+ for i in "${!removable[@]}"; do
+ if [[ -z "${worktrees[$i]}" ]]; then
+ deletable+=("${removable[$i]}")
+ elif git worktree remove "${worktrees[$i]}"; then
+ echo " Removed worktree ${worktrees[$i]}"
+ deletable+=("${removable[$i]}")
+ fi
+ done
+ if [[ ${#deletable[@]} -gt 0 ]]; then
+ message=$(git branch -D "${deletable[@]}")
+ echo "${message//Deleted/ Deleted}"
+ fi
fi
diff --git a/package.json b/package.json
index db853ad..352c58f 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"release": "standard-version -a",
"release:github": "github-generate-release",
"release:tags": "git push --follow-tags origin HEAD:master",
- "test": "exit 0"
+ "test": "bash test.sh"
},
"license": "MIT",
"commitlint": {
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..7b4b27a
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+set -uo pipefail
+
+GARBAGE="${GARBAGE:-$(cd "$(dirname "$0")" && pwd)/garbage.sh}"
+SANDBOX="$(mktemp -d)"
+trap 'rm -rf "$SANDBOX"' EXIT
+
+failures=0
+
+assert() {
+ local message="$1"
+ shift
+ if "$@"; then
+ echo " ✔ $message"
+ else
+ echo " ✖ $message" >&2
+ printf '%s\n' "${output:-}" >&2
+ failures=$((failures + 1))
+ fi
+}
+
+refute() {
+ local message="$1"
+ shift
+ if "$@"; then
+ echo " ✖ $message" >&2
+ printf '%s\n' "${output:-}" >&2
+ failures=$((failures + 1))
+ else
+ echo " ✔ $message"
+ fi
+}
+
+contains() {
+ case "$1" in
+ *"$2"*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+without_gh() {
+ mkdir -p "$SANDBOX/bin"
+ printf '#!/bin/sh\nexit 1\n' > "$SANDBOX/bin/gh"
+ chmod +x "$SANDBOX/bin/gh"
+ PATH="$SANDBOX/bin:$PATH"
+}
+
+git_quiet() {
+ git -c init.defaultBranch=master -c user.name=test -c user.email=test@test "$@" > /dev/null 2>&1
+}
+
+create_repo() {
+ local name="$1"
+ git_quiet init --bare "$SANDBOX/$name.git"
+ git_quiet clone "$SANDBOX/$name.git" "$SANDBOX/$name"
+ git_quiet -C "$SANDBOX/$name" commit --allow-empty -m init
+ git_quiet -C "$SANDBOX/$name" push -u origin master
+}
+
+merge_pushed_branch() {
+ local repo="$1" branch="$2"
+ git_quiet -C "$repo" switch -c "$branch"
+ git_quiet -C "$repo" commit --allow-empty -m "$branch"
+ git_quiet -C "$repo" push -u origin "$branch"
+ git_quiet -C "$repo" switch master
+ git_quiet -C "$repo" merge --ff-only "$branch"
+ git_quiet -C "$repo" push origin master
+ git_quiet -C "$repo" push origin --delete "$branch"
+}
+
+branch_exists() {
+ git -C "$1" show-ref --verify --quiet "refs/heads/$2"
+}
+
+run_garbage() {
+ (cd "$1" && printf 'y' | bash "$GARBAGE" 2>&1)
+}
+
+without_gh
+
+echo "merged branch checked out in a clean linked worktree"
+create_repo clean
+merge_pushed_branch "$SANDBOX/clean" feature
+git_quiet -C "$SANDBOX/clean" worktree add "$SANDBOX/clean-wt" feature
+output="$(run_garbage "$SANDBOX/clean")"
+refute "branch deleted" branch_exists "$SANDBOX/clean" feature
+refute "worktree removed" test -d "$SANDBOX/clean-wt"
+refute "no git error" contains "$output" "cannot delete branch"
+
+echo "merged branch checked out in a dirty linked worktree"
+create_repo dirty
+merge_pushed_branch "$SANDBOX/dirty" feature
+git_quiet -C "$SANDBOX/dirty" worktree add "$SANDBOX/dirty-wt" feature
+touch "$SANDBOX/dirty-wt/wip.txt"
+output="$(run_garbage "$SANDBOX/dirty")"
+assert "branch kept" branch_exists "$SANDBOX/dirty" feature
+assert "worktree kept" test -f "$SANDBOX/dirty-wt/wip.txt"
+assert "skip reason printed" contains "$output" "uncommitted changes"
+
+echo "merged branch checked out in a locked worktree"
+create_repo locked
+merge_pushed_branch "$SANDBOX/locked" feature
+git_quiet -C "$SANDBOX/locked" worktree add "$SANDBOX/locked-wt" feature
+git_quiet -C "$SANDBOX/locked" worktree lock "$SANDBOX/locked-wt"
+output="$(run_garbage "$SANDBOX/locked")"
+assert "branch kept" branch_exists "$SANDBOX/locked" feature
+assert "worktree kept" test -d "$SANDBOX/locked-wt"
+
+echo "run from a linked worktree never touches the main checkout branch"
+create_repo linked
+merge_pushed_branch "$SANDBOX/linked" feature
+git_quiet -C "$SANDBOX/linked" worktree add "$SANDBOX/linked-wt" feature
+output="$(run_garbage "$SANDBOX/linked-wt")"
+assert "main checkout branch kept" branch_exists "$SANDBOX/linked" master
+assert "main checkout kept" test -d "$SANDBOX/linked"
+
+echo "merged branch without worktree"
+create_repo plain
+merge_pushed_branch "$SANDBOX/plain" feature
+output="$(run_garbage "$SANDBOX/plain")"
+refute "branch deleted" branch_exists "$SANDBOX/plain" feature
+
+echo "stale worktree whose directory is gone"
+create_repo stale
+merge_pushed_branch "$SANDBOX/stale" feature
+git_quiet -C "$SANDBOX/stale" worktree add "$SANDBOX/stale-wt" feature
+rm -rf "$SANDBOX/stale-wt"
+output="$(run_garbage "$SANDBOX/stale")"
+refute "branch deleted" branch_exists "$SANDBOX/stale" feature
+
+if [[ $failures -gt 0 ]]; then
+ echo && echo "$failures failure(s)" >&2
+ exit 1
+fi
From 59f5db961a3c0922cd13161538cf893bb88f932e Mon Sep 17 00:00:00 2001
From: Kiko Beats
Date: Sun, 20 Sep 2026 21:35:35 +0200
Subject: [PATCH 2/2] fix: skip locked worktrees before announcing removal
A locked worktree was listed as removable and only survived because
git worktree remove failed after the prompt, so the user was told the
branch would go and the skip reason never printed. Classify it up front
from the porcelain locked attribute.
Also fail the test suite loudly when a git fixture command fails.
Co-Authored-By: Claude Opus 5 (1M context)
---
garbage.sh | 11 +++++++++++
test.sh | 7 ++++++-
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/garbage.sh b/garbage.sh
index a242016..ee62bad 100755
--- a/garbage.sh
+++ b/garbage.sh
@@ -26,6 +26,15 @@ worktree_of() {
'
}
+is_locked_worktree() {
+ printf '%s\n' "$worktree_list" | awk -v entry="worktree $1" '
+ $0 == entry { inside = 1; next }
+ inside && /^locked/ { locked = 1; exit }
+ inside && NF == 0 { exit }
+ END { exit locked ? 0 : 1 }
+ '
+}
+
# Check if a branch was part of remote workflow (pushed at some point)
was_pushed() {
local branch="$1"
@@ -104,6 +113,8 @@ for branch in "${branches[@]}"; do
worktrees+=("")
elif [[ "$worktree" == "$main_worktree" ]]; then
skipped+=("$branch (checked out in main worktree $worktree)")
+ elif is_locked_worktree "$worktree"; then
+ skipped+=("$branch (locked worktree $worktree)")
elif [[ -n "$(git -C "$worktree" status --porcelain 2> /dev/null)" ]]; then
skipped+=("$branch (uncommitted changes in worktree $worktree)")
else
diff --git a/test.sh b/test.sh
index 7b4b27a..0c8ced2 100755
--- a/test.sh
+++ b/test.sh
@@ -46,7 +46,10 @@ without_gh() {
}
git_quiet() {
- git -c init.defaultBranch=master -c user.name=test -c user.email=test@test "$@" > /dev/null 2>&1
+ if ! git -c init.defaultBranch=master -c user.name=test -c user.email=test@test "$@" > /dev/null 2>&1; then
+ echo " fixture failed: git $*" >&2
+ exit 1
+ fi
}
create_repo() {
@@ -105,6 +108,8 @@ git_quiet -C "$SANDBOX/locked" worktree lock "$SANDBOX/locked-wt"
output="$(run_garbage "$SANDBOX/locked")"
assert "branch kept" branch_exists "$SANDBOX/locked" feature
assert "worktree kept" test -d "$SANDBOX/locked-wt"
+assert "skip reason printed" contains "$output" "locked worktree"
+refute "not announced for removal" contains "$output" "and worktree"
echo "run from a linked worktree never touches the main checkout branch"
create_repo linked