Last active
October 9, 2025 21:48
-
-
Save kiliman/bf23ce1af5f7a49683f7c7b51895f662 to your computer and use it in GitHub Desktop.
Cleanup local branches after closing PR
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
| #!/bin/bash | |
| # Display help message | |
| show_help() { | |
| echo "Usage: $0 [options]" | |
| echo "" | |
| echo "This script cleans up or archives Git branches based on their PR status." | |
| echo "" | |
| echo "Options:" | |
| echo " --help Show this help message and exit." | |
| echo " --force Skip prompts and force delete/archive closed but unmerged branches." | |
| echo " --dry-run Simulate the process without making changes." | |
| echo " --open-pr Only process branches with open PRs." | |
| echo " --no-pr Only process branches with no associated PRs." | |
| echo " --archive Archive branches by renaming them with a 'zzz/' prefix instead of deleting." | |
| echo "" | |
| echo "Examples:" | |
| echo " $0 --dry-run See what branches would be deleted without touching anything." | |
| echo " $0 --archive Rename old branches to 'zzz/branch-name' for safekeeping." | |
| echo " $0 --force --no-pr Delete branches with no PRs, no questions asked." | |
| echo "" | |
| echo "Note: You need 'gh' and 'jq' installed. The script will offer to install them if missing." | |
| exit 0 | |
| } | |
| # Check if Homebrew is installed | |
| check_brew() { | |
| if ! command -v brew &> /dev/null; then | |
| echo "Error: Homebrew is not installed. Please install Homebrew first: https://brew.sh/" | |
| exit 1 | |
| fi | |
| } | |
| # Function to check and prompt for installation | |
| check_and_install() { | |
| local cmd=$1 | |
| local pkg=${2:-$1} # Default to cmd if pkg is not provided | |
| if ! command -v "$cmd" &> /dev/null; then | |
| echo "Error: $cmd is not installed." | |
| read -p "Would you like to install $cmd using Homebrew? (y/n): " answer | |
| if [[ "$answer" =~ ^[Yy]$ ]]; then | |
| check_brew | |
| echo "Installing $cmd..." | |
| brew install "$pkg" | |
| if [[ $? -ne 0 ]]; then | |
| echo "Error: Failed to install $cmd. Please install it manually." | |
| exit 1 | |
| fi | |
| else | |
| echo "Error: $cmd is required to run this script. Please install it manually." | |
| exit 1 | |
| fi | |
| fi | |
| } | |
| # Function to archive a branch | |
| archive_branch() { | |
| local branch=$1 | |
| local branch_sha=$2 | |
| local pr_number=$3 | |
| if [[ "$DRY_RUN" == false ]]; then | |
| git branch -m "$branch" "zzz/$branch" | |
| echo "$branch $branch_sha $pr_number" >> "$DELETED_BRANCHES_FILE" | |
| ((processed_count++)) | |
| else | |
| echo " Would archive branch $branch as zzz/$branch..." | |
| fi | |
| } | |
| # Function to delete a branch | |
| delete_branch() { | |
| local branch=$1 | |
| local branch_sha=$2 | |
| local pr_number=$3 | |
| if [[ "$DRY_RUN" == false ]]; then | |
| git branch -D "$branch" | |
| echo "$branch $branch_sha $pr_number" >> "$DELETED_BRANCHES_FILE" | |
| ((processed_count++)) | |
| else | |
| echo " Would delete branch $branch..." | |
| fi | |
| } | |
| # Check for required tools | |
| check_and_install gh | |
| check_and_install jq | |
| # Check if inside a git repository | |
| if ! git rev-parse --is-inside-work-tree &> /dev/null; then | |
| echo "Error: Not inside a Git repository." | |
| exit 1 | |
| fi | |
| # Check for flags | |
| FORCE=false | |
| DRY_RUN=false | |
| OPEN_PR=false | |
| NO_PR=false | |
| ARCHIVE=false | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --help) | |
| show_help | |
| ;; | |
| --force) | |
| FORCE=true | |
| shift | |
| ;; | |
| --dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| --open-pr) | |
| OPEN_PR=true | |
| shift | |
| ;; | |
| --no-pr) | |
| NO_PR=true | |
| shift | |
| ;; | |
| --archive) | |
| ARCHIVE=true | |
| shift | |
| ;; | |
| *) | |
| echo "Unknown option: $1" | |
| echo "Usage: $0 [--force] [--dry-run] [--open-pr] [--no-pr] [--archive] [--help]" | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # Validate flag combination | |
| if [[ "$OPEN_PR" == true && "$NO_PR" == true ]]; then | |
| echo "Error: --open-pr and --no-pr cannot be used together." | |
| exit 1 | |
| fi | |
| # File to store deleted/archived branch info | |
| DELETED_BRANCHES_FILE="tmp/deleted-branches.txt" | |
| # Ensure tmp directory and deleted-branches.txt exist (unless in dry-run mode) | |
| if [[ "$DRY_RUN" == false ]]; then | |
| mkdir -p tmp | |
| touch "$DELETED_BRANCHES_FILE" | |
| fi | |
| # Get the default branch (usually main or master) | |
| DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') | |
| # Get all local branches except the default branch and those starting with zzz/ | |
| branches=$(git branch --format='%(refname:short)' | grep -v "^$DEFAULT_BRANCH$" | grep -v "^zzz/") | |
| # Counter for deleted/archived branches | |
| processed_count=0 | |
| # Calculate timestamp for 30 days ago (in seconds since epoch) | |
| thirty_days_ago=$(date -d "30 days ago" +%s 2>/dev/null || date -v -30d +%s 2>/dev/null) | |
| for branch in $branches; do | |
| # Check if the branch is used by a worktree | |
| if git worktree list | grep -q "\[${branch}\]$"; then | |
| continue | |
| fi | |
| echo "Checking branch: $branch" | |
| # Get the SHA of the branch | |
| branch_sha=$(git rev-parse "$branch") | |
| # Check if branch is eligible for archiving (last commit more than 30 days ago) | |
| if [[ "$ARCHIVE" == true ]]; then | |
| last_commit_date=$(git log -1 --format=%ct "$branch") | |
| if [[ -z "$last_commit_date" || "$last_commit_date" -ge "$thirty_days_ago" ]]; then | |
| echo " Last commit on branch $branch is within the last 30 days. Skipping." | |
| continue | |
| fi | |
| fi | |
| # Check if the branch has an associated PR | |
| pr_info=$(gh pr view "$branch" --json number,state,createdAt,closedAt,mergedAt,isDraft,baseRefName,title 2>/dev/null) | |
| # Handle --no-pr flag | |
| if [[ "$NO_PR" == true ]]; then | |
| if [[ -n "$pr_info" ]]; then | |
| continue | |
| fi | |
| # Process branches with no PR for archiving or deletion | |
| if [[ "$ARCHIVE" == true ]]; then | |
| archive_branch "$branch" "$branch_sha" "no-pr" | |
| else | |
| delete_branch "$branch" "$branch_sha" "no-pr" | |
| fi | |
| continue | |
| fi | |
| # Handle --open-pr flag | |
| if [[ "$OPEN_PR" == true ]]; then | |
| if [[ -z "$pr_info" ]]; then | |
| continue | |
| fi | |
| pr_state=$(echo "$pr_info" | jq -r '.state') | |
| if [[ "$pr_state" != "OPEN" ]]; then | |
| continue | |
| fi | |
| fi | |
| if [[ -z "$pr_info" ]]; then | |
| echo " No PR found for branch $branch" | |
| continue | |
| fi | |
| # Parse PR information | |
| pr_number=$(echo "$pr_info" | jq -r '.number') | |
| pr_state=$(echo "$pr_info" | jq -r '.state') | |
| pr_created_at=$(echo "$pr_info" | jq -r '.createdAt') | |
| pr_merged_at=$(echo "$pr_info" | jq -r '.mergedAt') | |
| pr_closed_at=$(echo "$pr_info" | jq -r '.closedAt') | |
| pr_base_branch=$(echo "$pr_info" | jq -r '.baseRefName') | |
| pr_title=$(echo "$pr_info" | jq -r '.title') | |
| # Check if PR is merged | |
| if [[ "$pr_state" == "MERGED" ]]; then | |
| if [[ "$ARCHIVE" == true ]]; then | |
| archive_branch "$branch" "$branch_sha" "$pr_number" | |
| else | |
| delete_branch "$branch" "$branch_sha" "$pr_number" | |
| fi | |
| # Check if PR is closed but not merged | |
| elif [[ "$pr_state" == "CLOSED" && "$pr_merged_at" == "null" ]]; then | |
| echo " PR #$pr_number is closed but not merged." | |
| echo " PR Title: $pr_title" | |
| echo " Closed at: $pr_closed_at" | |
| echo " Base branch: $pr_base_branch" | |
| if [[ "$FORCE" == true ]]; then | |
| if [[ "$ARCHIVE" == true ]]; then | |
| archive_branch "$branch" "$branch_sha" "$pr_number" | |
| else | |
| delete_branch "$branch" "$branch_sha" "$pr_number" | |
| fi | |
| else | |
| read -p " Do you want to delete branch $branch? (y/n): " answer | |
| if [[ "$answer" =~ ^[Yy]$ ]]; then | |
| if [[ "$ARCHIVE" == true ]]; then | |
| archive_branch "$branch" "$branch_sha" "$pr_number" | |
| else | |
| delete_branch "$branch" "$branch_sha" "$pr_number" | |
| fi | |
| else | |
| echo " Keeping branch $branch" | |
| fi | |
| fi | |
| else | |
| echo " PR #$pr_number is still open or in draft state." | |
| echo " State: $pr_state" | |
| echo " Created At: $pr_created_at" | |
| fi | |
| done | |
| if [[ "$DRY_RUN" == true ]]; then | |
| echo "Dry-run complete. $processed_count branch(es) would have been processed." | |
| echo "No branches were actually deleted or renamed, and $DELETED_BRANCHES_FILE was not modified." | |
| else | |
| if [[ "$ARCHIVE" == true ]]; then | |
| echo "Archive complete. $processed_count branch(es) renamed." | |
| else | |
| echo "Cleanup complete. $processed_count branch(es) deleted." | |
| fi | |
| echo "Processed branches logged in $DELETED_BRANCHES_FILE" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment