Created
May 5, 2026 17:47
-
-
Save n0nick/84dbaa8db5357a768b8da3db3c92c626 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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