Skip to content

Instantly share code, notes, and snippets.

@petergi
Created February 27, 2026 21:57
Show Gist options
  • Select an option

  • Save petergi/5e22ba3801e2792bd81605f16a5dca80 to your computer and use it in GitHub Desktop.

Select an option

Save petergi/5e22ba3801e2792bd81605f16a5dca80 to your computer and use it in GitHub Desktop.
Bulk delete GitHub branches with whitelist protection
#!/usr/bin/env bash
# =============================================================================
# cleanup-branches.sh — Bulk delete GitHub branches with whitelist protection
# =============================================================================
#
# Usage:
# ./cleanup-branches.sh --repo owner/repo --whitelist whitelist.txt [OPTIONS]
#
# Options:
# --repo OWNER/REPO Target repository (required)
# --whitelist FILE Path to whitelist file (required)
# --dry-run Preview deletions without executing (default: on)
# --execute Actually delete branches (turns off dry-run)
# --batch-size N Branches to delete per batch (default: 50)
# --older-than DAYS Only delete branches with no commits in N days
# --log FILE Log deletions to file (default: cleanup-TIMESTAMP.log)
# --help Show this help
#
# Whitelist file format (one entry per line):
# main # Exact branch name
# develop # Exact branch name
# release/* # Glob pattern — protects release/1.0, release/2.0, etc.
# hotfix/* # Glob pattern
# feature/keep-* # Glob pattern
# # This is a comment # Lines starting with # are ignored
#
# =============================================================================
set -euo pipefail
# ─── Defaults ────────────────────────────────────────────────────────────────
DRY_RUN=true
REPO=""
WHITELIST_FILE=""
BATCH_SIZE=50
OLDER_THAN_DAYS=0
LOG_FILE="cleanup-$(date +%Y%m%d-%H%M%S).log"
DELETED_COUNT=0
SKIPPED_COUNT=0
PROTECTED_COUNT=0
ERROR_COUNT=0
# ─── Colors ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# ─── Helpers ─────────────────────────────────────────────────────────────────
log() { printf "%b\n" "${CYAN}[INFO]${NC} $*"; }
warn() { printf "%b\n" "${YELLOW}[WARN]${NC} $*"; }
error() { printf "%b\n" "${RED}[ERROR]${NC} $*" >&2; }
success(){ printf "%b\n" "${GREEN}[OK]${NC} $*"; }
usage() {
sed -n '/^# Usage:/,/^# =====/p' "$0" | sed 's/^# \?//'
exit 0
}
# ─── Parse arguments ────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--whitelist) WHITELIST_FILE="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--execute) DRY_RUN=false; shift ;;
--batch-size) BATCH_SIZE="$2"; shift 2 ;;
--older-than) OLDER_THAN_DAYS="$2"; shift 2 ;;
--log) LOG_FILE="$2"; shift 2 ;;
--help|-h) usage ;;
*) error "Unknown option: $1"; usage ;;
esac
done
# ─── Validate inputs ────────────────────────────────────────────────────────
if [[ -z "$REPO" ]]; then
error "Missing required --repo OWNER/REPO"
exit 1
fi
if [[ -z "$WHITELIST_FILE" ]]; then
error "Missing required --whitelist FILE"
exit 1
fi
if [[ ! -f "$WHITELIST_FILE" ]]; then
error "Whitelist file not found: $WHITELIST_FILE"
exit 1
fi
# Check gh CLI is available and authenticated
if ! command -v gh &>/dev/null; then
error "GitHub CLI (gh) is not installed. Install from https://cli.github.com"
exit 1
fi
if ! gh auth status &>/dev/null; then
error "Not authenticated. Run: gh auth login"
exit 1
fi
# ─── Load whitelist patterns ────────────────────────────────────────────────
declare -a WHITELIST_PATTERNS=()
while IFS= read -r line; do
# Strip leading/trailing whitespace
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
# Skip empty lines and comments
[[ -z "$line" || "$line" == \#* ]] && continue
WHITELIST_PATTERNS+=("$line")
done < "$WHITELIST_FILE"
if [[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]]; then
error "Whitelist file is empty or contains only comments."
exit 1
fi
log "Loaded ${BOLD}${#WHITELIST_PATTERNS[@]}${NC} whitelist patterns:"
for pattern in "${WHITELIST_PATTERNS[@]}"; do
printf "%b\n" " ${GREEN}✓${NC} $pattern"
done
echo ""
# ─── Check if branch matches any whitelist pattern ───────────────────────────
# Think of this like a bouncer at a club with a guest list:
# - Exact names = VIP list (e.g., "main")
# - Patterns with * = group passes (e.g., "release/*" lets in the whole release crew)
is_whitelisted() {
local branch="$1"
for pattern in "${WHITELIST_PATTERNS[@]}"; do
# Use bash pattern matching (supports *, ?, [])
# shellcheck disable=SC2254
case "$branch" in
$pattern) return 0 ;;
esac
done
return 1
}
# ─── Check branch age (last commit date) ────────────────────────────────────
is_old_enough() {
local branch="$1"
if [[ "$OLDER_THAN_DAYS" -eq 0 ]]; then
return 0 # No age filter
fi
local last_commit_date
last_commit_date=$(gh api "repos/$REPO/branches/$branch" \
--jq '.commit.commit.committer.date' 2>/dev/null || echo "")
if [[ -z "$last_commit_date" ]]; then
warn "Could not fetch commit date for '$branch', skipping age check"
return 0
fi
local last_commit_epoch cutoff_epoch
# Cross-platform date handling
if date --version &>/dev/null 2>&1; then
# GNU date (Linux)
last_commit_epoch=$(date -d "$last_commit_date" +%s)
cutoff_epoch=$(date -d "-${OLDER_THAN_DAYS} days" +%s)
else
# BSD date (macOS)
last_commit_epoch=$(date -jf "%Y-%m-%dT%H:%M:%SZ" "$last_commit_date" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S%z" "$last_commit_date" +%s 2>/dev/null || echo 0)
cutoff_epoch=$(date -j -v"-${OLDER_THAN_DAYS}d" +%s)
fi
[[ "$last_commit_epoch" -lt "$cutoff_epoch" ]]
}
# ─── Fetch all remote branches ──────────────────────────────────────────────
log "Fetching branches from ${BOLD}$REPO${NC}..."
ALL_BRANCHES=()
while IFS= read -r branch; do
ALL_BRANCHES+=("$branch")
done < <(gh api "repos/$REPO/branches" --paginate --jq '.[].name')
TOTAL=${#ALL_BRANCHES[@]}
log "Found ${BOLD}$TOTAL${NC} branches total."
echo ""
if [[ "$TOTAL" -eq 0 ]]; then
warn "No branches found. Check the repo name and your permissions."
exit 0
fi
# ─── Classify branches ──────────────────────────────────────────────────────
declare -a TO_DELETE=()
declare -a TO_KEEP=()
for branch in "${ALL_BRANCHES[@]}"; do
if is_whitelisted "$branch"; then
TO_KEEP+=("$branch")
((PROTECTED_COUNT++))
else
TO_DELETE+=("$branch")
fi
done
# ─── Summary before action ──────────────────────────────────────────────────
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
printf "%b\n" "${BOLD} Branch Cleanup Plan${NC}"
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
printf "%b\n" " Repository: ${BLUE}$REPO${NC}"
printf "%b\n" " Total branches: ${BOLD}$TOTAL${NC}"
printf "%b\n" " Protected: ${GREEN}$PROTECTED_COUNT${NC} (whitelist match)"
printf "%b\n" " To delete: ${RED}${#TO_DELETE[@]}${NC}"
if [[ "$OLDER_THAN_DAYS" -gt 0 ]]; then
printf "%b\n" " Age filter: older than ${YELLOW}${OLDER_THAN_DAYS} days${NC}"
fi
printf "%b\n" " Mode: $(if $DRY_RUN; then printf "%b\n" "${YELLOW}DRY RUN${NC}"; else printf "%b\n" "${RED}EXECUTE${NC}"; fi)"
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
# Show protected branches
if [[ ${#TO_KEEP[@]} -gt 0 ]]; then
log "Protected branches:"
for branch in "${TO_KEEP[@]}"; do
printf "%b\n" " ${GREEN}🛡${NC} $branch"
done
echo ""
fi
# Show branches to delete (first 20, then summary)
if [[ ${#TO_DELETE[@]} -gt 0 ]]; then
log "Branches to delete (showing first 20):"
for i in "${!TO_DELETE[@]}"; do
if [[ $i -ge 20 ]]; then
printf "%b\n" " ${YELLOW}...and $((${#TO_DELETE[@]} - 20)) more${NC}"
break
fi
printf "%b\n" " ${RED}✗${NC} ${TO_DELETE[$i]}"
done
echo ""
fi
# ─── Initialize log file ────────────────────────────────────────────────────
{
echo "# Branch Cleanup Log"
echo "# Repository: $REPO"
echo "# Date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "# Mode: $(if $DRY_RUN; then echo 'DRY RUN'; else echo 'EXECUTE'; fi)"
echo "# Whitelist patterns: ${WHITELIST_PATTERNS[*]}"
echo "#"
echo "# Format: STATUS | BRANCH_NAME | DETAIL"
echo "# ──────────────────────────────────────"
} > "$LOG_FILE"
for branch in "${TO_KEEP[@]}"; do
echo "PROTECTED | $branch | whitelist match" >> "$LOG_FILE"
done
# ─── Dry run mode ───────────────────────────────────────────────────────────
if $DRY_RUN; then
warn "DRY RUN — no branches will be deleted."
warn "Review the plan above, then re-run with ${BOLD}--execute${NC} to delete."
echo ""
for branch in "${TO_DELETE[@]}"; do
echo "WOULD_DELETE | $branch" >> "$LOG_FILE"
done
printf "%b\n" "Log written to: ${CYAN}$LOG_FILE${NC}"
exit 0
fi
# ─── Confirmation prompt ────────────────────────────────────────────────────
printf "%b\n" "${RED}${BOLD}⚠ WARNING: This will permanently delete ${#TO_DELETE[@]} branches from $REPO${NC}"
printf "%b\n" "${RED}${BOLD} This action cannot be undone!${NC}"
echo ""
read -rp "Type the repo name to confirm (${REPO}): " CONFIRM
if [[ "$CONFIRM" != "$REPO" ]]; then
error "Confirmation failed. Aborting."
exit 1
fi
echo ""
# ─── Delete branches in batches ─────────────────────────────────────────────
log "Starting deletion in batches of $BATCH_SIZE..."
echo ""
BATCH_NUM=0
for i in "${!TO_DELETE[@]}"; do
branch="${TO_DELETE[$i]}"
# Age filter (only checked at deletion time to avoid slow pre-scan)
if [[ "$OLDER_THAN_DAYS" -gt 0 ]]; then
if ! is_old_enough "$branch"; then
printf "%b\n" " ${YELLOW}⏭${NC} $branch (too recent, skipping)"
echo "SKIPPED_AGE | $branch | less than ${OLDER_THAN_DAYS} days old" >> "$LOG_FILE"
((SKIPPED_COUNT++))
continue
fi
fi
# Delete the branch via GitHub API (faster than git push for bulk ops)
# URL-encode the branch name for the API call
encoded_branch=$(printf '%s' "$branch" | jq -sRr @uri)
if gh api --method DELETE "repos/$REPO/git/refs/heads/${encoded_branch}" --silent 2>/dev/null; then
printf "%b\n" " ${RED}🗑${NC} $branch"
echo "DELETED | $branch | $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$LOG_FILE"
((DELETED_COUNT++))
else
printf "%b\n" " ${YELLOW}⚠${NC} $branch (failed — may be protected or already deleted)"
echo "ERROR | $branch | deletion failed" >> "$LOG_FILE"
((ERROR_COUNT++))
fi
# Batch pause to avoid rate limiting
if (( (i + 1) % BATCH_SIZE == 0 )); then
((BATCH_NUM++))
log "Batch $BATCH_NUM complete ($((i + 1))/${#TO_DELETE[@]}). Pausing 2s for rate limits..."
sleep 2
fi
done
# ─── Final report ───────────────────────────────────────────────────────────
echo ""
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
printf "%b\n" "${BOLD} Cleanup Complete${NC}"
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
printf "%b\n" " ${GREEN}Deleted:${NC} $DELETED_COUNT"
printf "%b\n" " ${GREEN}Protected:${NC} $PROTECTED_COUNT"
printf "%b\n" " ${YELLOW}Skipped:${NC} $SKIPPED_COUNT (age filter)"
printf "%b\n" " ${RED}Errors:${NC} $ERROR_COUNT"
printf "%b\n" " ${CYAN}Log file:${NC} $LOG_FILE"
printf "%b\n" "${BOLD}═══════════════════════════════════════════════════${NC}"
# =============================================================================
# Branch Whitelist — branches matching these patterns will NEVER be deleted
# =============================================================================
# Supports:
# - Exact names: main
# - Glob patterns: release/* (matches release/1.0, release/2.0-rc1, etc.)
# - Wildcards: feature/keep-*
# - Comments: lines starting with #
# =============================================================================
# ─── Core branches ───────────────────────────────────────────────────────────
main
master
develop
development
# ─── Release branches ────────────────────────────────────────────────────────
release/*
hotfix/*
# ─── Add your custom patterns below ─────────────────────────────────────────
# feature/important-*
# bugfix/critical-*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment