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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ npm install --global git-garbage
<br>
</p>

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.
Expand Down
70 changes: 66 additions & 4 deletions garbage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,31 @@ if ! git fetch --prune --quiet 2> /dev/null; then
exit 1;
fi

git worktree prune
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 }
'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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"
Expand Down Expand Up @@ -83,19 +104,60 @@ 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 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
removable+=("$branch")
worktrees+=("$worktree")
fi
Comment thread
cursor[bot] marked this conversation as resolved.
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
139 changes: 139 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -uo pipefail
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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() {
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() {
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"
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
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
Loading