Created
February 27, 2026 21:57
-
-
Save petergi/5e22ba3801e2792bd81605f16a5dca80 to your computer and use it in GitHub Desktop.
Bulk delete GitHub branches with whitelist protection
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 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}" |
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
| # ============================================================================= | |
| # 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