Skip to content

Instantly share code, notes, and snippets.

@n0nick
Created May 5, 2026 17:47
Show Gist options
  • Select an option

  • Save n0nick/84dbaa8db5357a768b8da3db3c92c626 to your computer and use it in GitHub Desktop.

Select an option

Save n0nick/84dbaa8db5357a768b8da3db3c92c626 to your computer and use it in GitHub Desktop.
#!/usr/bin/env zsh
set -euo pipefail
# Cleanup local and remote branches that have merged/closed PRs.
# Scans the current git repo, checks GitHub PR status via gh CLI,
# and prompts before deleting.
if ! git rev-parse --is-inside-work-tree &>/dev/null; then
echo "Not a git repository." >&2
exit 1
fi
if ! command -v gh &>/dev/null; then
echo "gh CLI is required." >&2
exit 1
fi
repo=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || {
echo "Could not determine GitHub repo." >&2
exit 1
}
main_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||') || main_branch="main"
current_branch=$(git branch --show-current)
# Collect branches currently checked out in a worktree (can't delete without removing worktree first)
worktree_branches=()
while IFS= read -r line; do
[[ "$line" == branch* ]] && worktree_branches+=("${line#branch refs/heads/}")
done < <(git worktree list --porcelain | grep '^branch ')
# Parallel arrays: branches[i], locations[i], pr_states[i], pr_infos[i]
branches=()
locations=()
pr_states=()
pr_infos=()
find_branch() {
local name=$1
for i in {1..${#branches[@]}}; do
[[ "${branches[$i]}" == "$name" ]] && echo "$i" && return
done
echo ""
}
# Local branches
while IFS= read -r branch; do
[[ -z "$branch" ]] && continue
[[ "$branch" == "$main_branch" || "$branch" == "master" ]] && continue
[[ "$branch" == "$current_branch" ]] && continue
[[ "$branch" == worktree-* ]] && continue
branches+=("$branch")
locations+=("local")
done < <(git branch --format='%(refname:short)')
# Remote branches (origin, excluding main/master/HEAD, only user-prefixed)
git fetch origin --prune &>/dev/null || true
while IFS= read -r rbranch; do
[[ -z "$rbranch" ]] && continue
branch="${rbranch#origin/}"
[[ "$branch" == "$main_branch" || "$branch" == "master" ]] && continue
if [[ "$branch" =~ ^(sm/|sagie/) ]]; then
idx=$(find_branch "$branch")
if [[ -n "$idx" ]]; then
locations[$idx]="both"
else
branches+=("$branch")
locations+=("remote")
fi
fi
done < <(git branch -r --format='%(refname:short)' | grep '^origin/')
if [[ ${#branches[@]} -eq 0 ]]; then
echo "No branches to check."
exit 0
fi
echo "Checking ${#branches[@]} branches against $repo..."
echo ""
# Check PR status for each branch
for i in {1..${#branches[@]}}; do
branch="${branches[$i]}"
result=$(gh pr list --repo "$repo" --head "$branch" --state all --json number,state,title --limit 1 2>/dev/null)
if [[ -z "$result" || "$result" == "[]" ]]; then
pr_states+=("NO_PR")
pr_infos+=("no PR found")
else
state=$(echo "$result" | jq -r '.[0].state')
number=$(echo "$result" | jq -r '.[0].number')
title=$(echo "$result" | jq -r '.[0].title')
pr_states+=("$state")
pr_infos+=("#$number $title")
fi
done
# Categorize into deletable / keep / no-pr index lists
deletable=()
keepable=()
no_pr=()
for i in {1..${#branches[@]}}; do
case "${pr_states[$i]}" in
MERGED|CLOSED) deletable+=("$i") ;;
OPEN) keepable+=("$i") ;;
NO_PR) no_pr+=("$i") ;;
esac
done
# Display results
if [[ ${#keepable[@]} -gt 0 ]]; then
echo "=== KEEPING (open PRs) ==="
for i in "${keepable[@]}"; do
printf " %-45s %s [%s]\n" "${branches[$i]}" "${pr_infos[$i]}" "${locations[$i]}"
done
echo ""
fi
if [[ ${#no_pr[@]} -gt 0 ]]; then
echo "=== NO PR (skipping) ==="
for i in "${no_pr[@]}"; do
printf " %-45s [%s]\n" "${branches[$i]}" "${locations[$i]}"
done
echo ""
fi
if [[ ${#deletable[@]} -eq 0 ]]; then
echo "Nothing to clean up!"
exit 0
fi
echo "=== CAN DELETE (merged/closed PRs) ==="
for n in {1..${#deletable[@]}}; do
i="${deletable[$n]}"
printf " %2d) %-45s %-6s %s [%s]\n" "$n" "${branches[$i]}" "${pr_states[$i]}" "${pr_infos[$i]}" "${locations[$i]}"
done
echo ""
read -r "answer?Delete all ${#deletable[@]} branches? [y/N/numbers e.g. 1,3,5] "
if [[ -z "$answer" || "$answer" == "n" || "$answer" == "N" ]]; then
echo "Aborted."
exit 0
fi
# Parse selection
selected=()
if [[ "$answer" == "y" || "$answer" == "Y" ]]; then
selected=("${deletable[@]}")
else
IFS=',' read -rA indices <<< "$answer"
for idx in "${indices[@]}"; do
idx=$(echo "$idx" | tr -d ' ')
if [[ "$idx" =~ ^[0-9]+$ ]] && (( idx >= 1 && idx <= ${#deletable[@]} )); then
selected+=("${deletable[$idx]}")
fi
done
fi
if [[ ${#selected[@]} -eq 0 ]]; then
echo "No valid selection."
exit 0
fi
for i in "${selected[@]}"; do
branch="${branches[$i]}"
loc="${locations[$i]}"
if [[ "$loc" == "local" || "$loc" == "both" ]]; then
# Remove any worktree using this branch first
worktree_path=$(git worktree list --porcelain | awk -v b="refs/heads/$branch" '/^worktree /{wt=$2} /^branch /{if ($2 == b) print wt}')
if [[ -n "$worktree_path" && "$worktree_path" != "$(git rev-parse --show-toplevel)" ]]; then
echo " Removing worktree $worktree_path..."
git worktree remove --force "$worktree_path" 2>&1 | sed 's/^/ /'
fi
git branch -D "$branch" 2>&1 | sed 's/^/ /'
fi
if [[ "$loc" == "remote" || "$loc" == "both" ]]; then
git push origin --delete "$branch" 2>&1 | sed 's/^/ /'
fi
done
echo ""
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment