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