Skip to content

Instantly share code, notes, and snippets.

@kiliman
Last active October 9, 2025 21:48
Show Gist options
  • Save kiliman/bf23ce1af5f7a49683f7c7b51895f662 to your computer and use it in GitHub Desktop.
Save kiliman/bf23ce1af5f7a49683f7c7b51895f662 to your computer and use it in GitHub Desktop.
Cleanup local branches after closing PR
#!/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