Last active
July 31, 2025 19:23
-
-
Save nelson-ens/34db9ad1b64ed5ab1ed720e0b78a4b97 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
| #!/bin/bash | |
| # Git Branch Cleanup Script | |
| # Safely removes merged branches locally and remotely | |
| # Based on: https://www.baeldung.com/ops/git-remove-merged-branches | |
| set -e | |
| # Default configuration | |
| DRY_RUN=true | |
| PROTECTED_BRANCHES=("main" "master" "develop" "development" "dev" "staging" "stage" "production" "prod" "release" "release-") | |
| REMOTE="origin" | |
| VERBOSE=false | |
| CLEANUP_MODE="merged" # Options: merged, stale, both | |
| STALE_DAYS=30 | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' # No Color | |
| # Usage function | |
| usage() { | |
| cat << EOF | |
| Usage: $0 [OPTIONS] | |
| Git Branch Cleanup Script - Safely remove merged branches locally and remotely | |
| OPTIONS: | |
| -h, --help Show this help message | |
| -d, --dry-run Show what would be deleted (default: true) | |
| -x, --execute Actually perform deletions (turns off dry-run) | |
| -r, --remote REMOTE Specify remote name (default: origin) | |
| -i, --ignore BRANCH Add branch to ignore list (can be used multiple times) | |
| -v, --verbose Verbose output | |
| -l, --local-only Only clean up local branches | |
| -R, --remote-only Only clean up remote branches | |
| -t, --test-pattern BRANCH Test if a branch name matches protection patterns (debug) | |
| -m, --mode MODE Cleanup mode: merged, stale, both (default: merged) | |
| -s, --stale-days DAYS Consider branches stale after N days (default: 30) | |
| EXAMPLES: | |
| $0 # Dry run with default settings (merged branches only) | |
| $0 --execute # Actually perform cleanup | |
| $0 -i feature-branch -i hotfix-123 # Ignore specific branches | |
| $0 -i "feature-.*" -i "hotfix/.*" # Ignore branches matching regex patterns | |
| $0 -i "^(staging|qa)-.*" # Ignore branches starting with staging- or qa- | |
| $0 --execute --local-only # Only clean local branches | |
| $0 --execute --remote origin # Specify different remote | |
| $0 --mode stale --stale-days 60 # Delete remote branches older than 60 days | |
| $0 --mode both --stale-days 14 # Delete both merged and stale (14+ days) branches | |
| $0 --execute --mode stale --remote-only # Delete only stale remote branches | |
| CLEANUP MODES: | |
| merged Delete only branches that have been merged (default) | |
| stale Delete only branches older than specified days (regardless of merge status) | |
| both Delete branches that are either merged OR older than specified days | |
| REGEX PATTERNS: | |
| Patterns containing regex metacharacters (*, ., [, ], ^, $, +, ?, {, }, \\) | |
| are automatically treated as regular expressions. | |
| Examples: | |
| - "feature-.*" matches feature-login, feature-api, etc. | |
| - "^hotfix/.*" matches branches starting with hotfix/ | |
| - ".*-(staging|prod)" matches branches ending with -staging or -prod | |
| - "issue-[0-9]+" matches issue-123, issue-456, etc. | |
| PROTECTED BRANCHES (always ignored): | |
| ${PROTECTED_BRANCHES[*]} | |
| Note: Patterns with regex metacharacters are treated as regular expressions. | |
| Both exact matches and regex patterns are supported. | |
| EOF | |
| } | |
| # Logging functions | |
| log_info() { | |
| echo -e "${BLUE}[INFO]${NC} $1" | |
| } | |
| log_success() { | |
| echo -e "${GREEN}[SUCCESS]${NC} $1" | |
| } | |
| log_warning() { | |
| echo -e "${YELLOW}[WARNING]${NC} $1" | |
| } | |
| log_error() { | |
| echo -e "${RED}[ERROR]${NC} $1" | |
| } | |
| log_dry_run() { | |
| echo -e "${CYAN}[DRY RUN]${NC} $1" | |
| } | |
| log_verbose() { | |
| if [[ "$VERBOSE" == true ]]; then | |
| echo -e "${YELLOW}[VERBOSE]${NC} $1" >&2 | |
| fi | |
| } | |
| # Calculate cutoff date for stale branches (cross-platform) | |
| calculate_cutoff_date() { | |
| local days_ago="$1" | |
| local cutoff_date | |
| # Validate input | |
| if ! [[ "$days_ago" =~ ^[0-9]+$ ]] || [[ "$days_ago" -lt 1 ]]; then | |
| log_error "Invalid days_ago value: '$days_ago'. Must be a positive integer." | |
| return 1 | |
| fi | |
| # Try GNU date first (Linux, or installed via coreutils on macOS) | |
| if command -v gdate >/dev/null 2>&1; then | |
| if cutoff_date=$(gdate -d "${days_ago} days ago" +%s 2>/dev/null); then | |
| echo "$cutoff_date" | |
| return 0 | |
| fi | |
| fi | |
| # Try BSD date (macOS default) | |
| if cutoff_date=$(date -v-${days_ago}d +%s 2>/dev/null); then | |
| echo "$cutoff_date" | |
| return 0 | |
| fi | |
| # Try GNU date with different syntax (some Linux distributions) | |
| if cutoff_date=$(date -d "${days_ago} days ago" +%s 2>/dev/null); then | |
| echo "$cutoff_date" | |
| return 0 | |
| fi | |
| # Fallback: approximate calculation using basic arithmetic | |
| log_warning "Using approximate date calculation (may be less accurate)" | |
| local current_epoch now_epoch | |
| current_epoch=$(date +%s 2>/dev/null) | |
| if [[ -n "$current_epoch" ]]; then | |
| # 86400 seconds per day | |
| cutoff_date=$((current_epoch - (days_ago * 86400))) | |
| echo "$cutoff_date" | |
| return 0 | |
| fi | |
| log_error "Unable to calculate cutoff date. Please install GNU coreutils or ensure your system's date command supports date arithmetic." | |
| return 1 | |
| } | |
| # Format epoch time to human readable format (cross-platform) | |
| format_epoch_date() { | |
| local epoch="$1" | |
| if command -v gdate >/dev/null 2>&1; then | |
| gdate -r "$epoch" '+%Y-%m-%d' 2>/dev/null || echo "unknown" | |
| else | |
| date -r "$epoch" '+%Y-%m-%d' 2>/dev/null || echo "unknown" | |
| fi | |
| } | |
| # Check system requirements | |
| check_system_requirements() { | |
| # Check for required commands | |
| local missing_commands=() | |
| if ! command -v git >/dev/null 2>&1; then | |
| missing_commands+=("git") | |
| fi | |
| if ! command -v timeout >/dev/null 2>&1; then | |
| log_warning "timeout command not found - regex timeout protection disabled" | |
| fi | |
| if [[ ${#missing_commands[@]} -gt 0 ]]; then | |
| log_error "Missing required commands: ${missing_commands[*]}" | |
| log_error "Please install the missing commands and try again" | |
| exit 1 | |
| fi | |
| # Check Git version (minimum 2.0 for some features) | |
| local git_version | |
| git_version=$(git --version 2>/dev/null | grep -o '[0-9]\+\.[0-9]\+' | head -1) | |
| if [[ -n "$git_version" ]]; then | |
| local major minor | |
| major=$(echo "$git_version" | cut -d. -f1) | |
| minor=$(echo "$git_version" | cut -d. -f2) | |
| if [[ "$major" -lt 2 ]] || ([[ "$major" -eq 1 ]] && [[ "$minor" -lt 8 ]]); then | |
| log_warning "Git version $git_version is quite old. Some features may not work correctly." | |
| log_warning "Consider upgrading to Git 2.0 or later for best compatibility." | |
| fi | |
| fi | |
| } | |
| # Check if we're in a git repository | |
| check_git_repo() { | |
| if ! git rev-parse --git-dir > /dev/null 2>&1; then | |
| log_error "Not in a git repository" | |
| log_error "Please run this script from within a Git repository" | |
| log_info "To initialize a Git repository, run: git init" | |
| exit 1 | |
| fi | |
| # Check if there are any commits | |
| if ! git rev-parse --verify HEAD >/dev/null 2>&1; then | |
| log_warning "Repository has no commits yet" | |
| log_info "No branches to clean up in an empty repository" | |
| exit 0 | |
| fi | |
| } | |
| # Check if remote exists | |
| check_remote() { | |
| if ! git remote | grep -q "^${REMOTE}$"; then | |
| log_error "Remote '${REMOTE}' does not exist" | |
| log_info "Available remotes: $(git remote | tr '\n' ' ')" | |
| exit 1 | |
| fi | |
| } | |
| # Validate regex pattern to prevent ReDoS attacks | |
| validate_regex_pattern() { | |
| local pattern="$1" | |
| # Check for common ReDoS patterns using simpler string matching | |
| # 1. Nested quantifiers like (a+)+ or (a*)* | |
| if [[ "$pattern" == *")+"* ]] || [[ "$pattern" == *")*"* ]]; then | |
| if [[ "$pattern" == *"("*"+"*")+"* ]] || [[ "$pattern" == *"("*"*"*")*"* ]]; then | |
| log_warning "Potentially dangerous regex pattern detected (nested quantifiers): '$pattern'" | |
| return 1 | |
| fi | |
| fi | |
| # 2. Excessive alternation with quantifiers like (a|a)* | |
| if [[ "$pattern" == *"|"* ]] && ([[ "$pattern" == *")+"* ]] || [[ "$pattern" == *")*"* ]]); then | |
| local alternation_count | |
| alternation_count=$(echo "$pattern" | grep -o "|" | wc -l | tr -d ' ') | |
| if [[ $alternation_count -gt 10 ]]; then | |
| log_warning "Potentially dangerous regex pattern detected (excessive alternation): '$pattern'" | |
| return 1 | |
| fi | |
| fi | |
| # 3. Long repetition ranges like {1,10000} | |
| if [[ "$pattern" =~ \{[0-9]+,[0-9]+\} ]]; then | |
| local max_repeat | |
| max_repeat=$(echo "$pattern" | sed -n 's/.*{\([0-9]\+\),\([0-9]\+\)}.*/\2/p' | head -1) | |
| if [[ -n "$max_repeat" ]] && [[ $max_repeat -gt 1000 ]]; then | |
| log_warning "Potentially dangerous regex pattern detected (excessive repetition): '$pattern'" | |
| return 1 | |
| fi | |
| fi | |
| return 0 | |
| } | |
| # Check if branch is protected (supports exact match and regex patterns) | |
| is_protected_branch() { | |
| local branch="$1" | |
| # Sanitize branch name - remove any control characters | |
| branch=$(echo "$branch" | tr -d '\000-\037\177') | |
| for protected in "${PROTECTED_BRANCHES[@]}"; do | |
| log_verbose "Testing branch '$branch' against pattern '$protected'" | |
| # First try exact match | |
| if [[ "$branch" == "$protected" ]]; then | |
| log_verbose "Branch '$branch' matches protected pattern (exact): '$protected'" | |
| return 0 | |
| fi | |
| # Check if pattern contains regex metacharacters | |
| if [[ "$protected" == *"*"* ]] || [[ "$protected" == *"."* ]] || [[ "$protected" == *"["* ]] || \ | |
| [[ "$protected" == *"]"* ]] || [[ "$protected" == *"^"* ]] || [[ "$protected" == *"$"* ]] || \ | |
| [[ "$protected" == *"+"* ]] || [[ "$protected" == *"?"* ]] || [[ "$protected" == *"{"* ]] || \ | |
| [[ "$protected" == *"}"* ]] || [[ "$protected" == *"\\"* ]]; then | |
| log_verbose "Pattern '$protected' detected as regex" | |
| # Validate regex pattern for safety | |
| if ! validate_regex_pattern "$protected"; then | |
| log_error "Skipping unsafe regex pattern: '$protected'" | |
| continue | |
| fi | |
| # Use bash regex matching with timeout protection if available | |
| if command -v timeout >/dev/null 2>&1; then | |
| if timeout 5s bash -c "[[ '$branch' =~ ^${protected}$ ]]" 2>/dev/null; then | |
| log_verbose "Branch '$branch' matches protected pattern (regex): '$protected'" | |
| return 0 | |
| elif [[ $? -eq 124 ]]; then | |
| log_warning "Regex pattern '$protected' timed out - treating as non-match" | |
| fi | |
| else | |
| # No timeout protection available - use direct matching with warning | |
| if [[ "$branch" =~ ^${protected}$ ]]; then | |
| log_verbose "Branch '$branch' matches protected pattern (regex): '$protected'" | |
| return 0 | |
| fi | |
| fi | |
| fi | |
| done | |
| log_verbose "Branch '$branch' does not match any protected patterns" | |
| return 1 | |
| } | |
| # Fetch latest changes | |
| fetch_updates() { | |
| log_info "Fetching latest changes from remote..." | |
| # Check if remote is reachable | |
| if ! git ls-remote --exit-code "$REMOTE" >/dev/null 2>&1; then | |
| log_error "Remote '$REMOTE' is not reachable. Check your network connection and remote URL." | |
| exit 1 | |
| fi | |
| if git fetch --prune "$REMOTE" 2>/dev/null; then | |
| log_success "Successfully fetched updates" | |
| else | |
| local exit_code=$? | |
| case $exit_code in | |
| 1) log_error "Failed to fetch updates - authentication or permission error" ;; | |
| 128) log_error "Failed to fetch updates - not a git repository or remote not found" ;; | |
| *) log_error "Failed to fetch updates (exit code: $exit_code)" ;; | |
| esac | |
| exit 1 | |
| fi | |
| } | |
| # Get current branch | |
| get_current_branch() { | |
| local current_branch | |
| current_branch=$(git branch --show-current 2>/dev/null) | |
| # Handle detached HEAD state | |
| if [[ -z "$current_branch" ]]; then | |
| if git rev-parse --verify HEAD >/dev/null 2>&1; then | |
| log_warning "Currently in detached HEAD state" | |
| current_branch="HEAD" | |
| else | |
| log_error "Unable to determine current branch state" | |
| return 1 | |
| fi | |
| fi | |
| echo "$current_branch" | |
| } | |
| # Get merged local branches (excluding current branch and protected branches) | |
| get_merged_local_branches() { | |
| local current_branch | |
| current_branch=$(get_current_branch) | |
| local merged_branches=() | |
| log_verbose "Checking for merged local branches..." | |
| # Get all merged branches | |
| while IFS= read -r branch; do | |
| # Remove leading/trailing whitespace and asterisk | |
| branch=$(echo "$branch" | sed 's/^[* ]*//' | sed 's/[ ]*$//') | |
| # Skip current branch | |
| [[ "$branch" == "$current_branch" ]] && continue | |
| # Skip protected branches (including regex patterns) | |
| if is_protected_branch "$branch"; then | |
| log_verbose "Skipping protected local branch: $branch" | |
| continue | |
| fi | |
| log_verbose "Found merged local branch: $branch" | |
| merged_branches+=("$branch") | |
| done < <(git branch --merged) | |
| printf '%s\n' "${merged_branches[@]}" | |
| } | |
| # Get stale local branches (older than specified days) | |
| get_stale_local_branches() { | |
| local current_branch stale_branches=() cutoff_date | |
| current_branch=$(get_current_branch) || return 1 | |
| cutoff_date=$(calculate_cutoff_date "$STALE_DAYS") || return 1 | |
| log_verbose "Checking for local branches older than ${STALE_DAYS} days..." | |
| # Get all local branches with their last commit dates | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| local branch_date branch_name | |
| branch_date=$(echo "$line" | cut -d'|' -f1) | |
| branch_name=$(echo "$line" | cut -d'|' -f2 | sed 's/^[* ]*//' | sed 's/[ ]*$//') | |
| # Validate branch_date is numeric | |
| if ! [[ "$branch_date" =~ ^[0-9]+$ ]]; then | |
| log_verbose "Skipping branch with invalid date: $branch_name" | |
| continue | |
| fi | |
| # Skip current branch | |
| [[ "$branch_name" == "$current_branch" ]] && continue | |
| # Skip protected branches | |
| if is_protected_branch "$branch_name"; then | |
| log_verbose "Skipping protected local branch: $branch_name" | |
| continue | |
| fi | |
| # Check if branch is older than cutoff | |
| if [[ "$branch_date" -lt "$cutoff_date" ]]; then | |
| local formatted_date | |
| formatted_date=$(format_epoch_date "$branch_date") | |
| log_verbose "Found stale local branch: $branch_name (last updated: $formatted_date)" | |
| stale_branches+=("$branch_name") | |
| fi | |
| done < <(git for-each-ref --format='%(committerdate:unix)|%(refname:short)' refs/heads/ 2>/dev/null) | |
| printf '%s\n' "${stale_branches[@]}" | |
| } | |
| # Get stale remote branches (older than specified days) | |
| get_stale_remote_branches() { | |
| local stale_branches=() cutoff_date | |
| cutoff_date=$(calculate_cutoff_date "$STALE_DAYS") || return 1 | |
| log_verbose "Checking for remote branches older than ${STALE_DAYS} days..." | |
| # Get all remote branches with their last commit dates | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| local branch_date branch_name | |
| branch_date=$(echo "$line" | cut -d'|' -f1) | |
| branch_name=$(echo "$line" | cut -d'|' -f2 | sed "s|^[ ]*${REMOTE}/||" | sed 's/[ ]*$//') | |
| # Validate branch_date is numeric | |
| if ! [[ "$branch_date" =~ ^[0-9]+$ ]]; then | |
| log_verbose "Skipping remote branch with invalid date: $branch_name" | |
| continue | |
| fi | |
| # Skip HEAD reference | |
| [[ "$branch_name" == "HEAD" ]] && continue | |
| # Skip protected branches | |
| if is_protected_branch "$branch_name"; then | |
| log_verbose "Skipping protected remote branch: $branch_name" | |
| continue | |
| fi | |
| # Check if branch is older than cutoff | |
| if [[ "$branch_date" -lt "$cutoff_date" ]]; then | |
| local formatted_date | |
| formatted_date=$(format_epoch_date "$branch_date") | |
| log_verbose "Found stale remote branch: $branch_name (last updated: $formatted_date)" | |
| stale_branches+=("$branch_name") | |
| fi | |
| done < <(git for-each-ref --format='%(committerdate:unix)|%(refname:short)' "refs/remotes/${REMOTE}/" 2>/dev/null | grep -v "HEAD") | |
| printf '%s\n' "${stale_branches[@]}" | |
| } | |
| get_merged_remote_branches() { | |
| local merged_branches=() | |
| log_verbose "Checking for merged remote branches..." | |
| # Get all merged remote branches | |
| while IFS= read -r branch; do | |
| # Remove remote prefix and clean up | |
| branch=$(echo "$branch" | sed "s|^[ ]*${REMOTE}/||" | sed 's/[ ]*$//') | |
| # Skip HEAD reference | |
| [[ "$branch" == "HEAD" ]] && continue | |
| # Skip protected branches (including regex patterns) | |
| if is_protected_branch "$branch"; then | |
| log_verbose "Skipping protected remote branch: $branch" | |
| continue | |
| fi | |
| log_verbose "Found merged remote branch: $branch" | |
| merged_branches+=("$branch") | |
| done < <(git branch -r --merged | grep "^[ ]*${REMOTE}/" | grep -v "HEAD") | |
| printf '%s\n' "${merged_branches[@]}" | |
| } | |
| # Validate branch exists locally | |
| branch_exists_locally() { | |
| local branch="$1" | |
| git rev-parse --verify "refs/heads/$branch" >/dev/null 2>&1 | |
| } | |
| # Validate branch exists on remote | |
| branch_exists_remotely() { | |
| local branch="$1" | |
| git rev-parse --verify "refs/remotes/${REMOTE}/$branch" >/dev/null 2>&1 | |
| } | |
| # Delete local branches | |
| delete_local_branches() { | |
| local branches=("$@") | |
| local valid_branches=() | |
| if [[ ${#branches[@]} -eq 0 ]]; then | |
| log_info "No local branches to delete" | |
| return | |
| fi | |
| # Validate branches exist | |
| for branch in "${branches[@]}"; do | |
| if branch_exists_locally "$branch"; then | |
| valid_branches+=("$branch") | |
| else | |
| log_warning "Local branch '$branch' no longer exists, skipping" | |
| fi | |
| done | |
| if [[ ${#valid_branches[@]} -eq 0 ]]; then | |
| log_info "No valid local branches to delete" | |
| return | |
| fi | |
| log_info "Local branches to delete:" | |
| for branch in "${valid_branches[@]}"; do | |
| echo " - $branch" | |
| done | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_dry_run "Would delete ${#valid_branches[@]} local branch(es)" | |
| return | |
| fi | |
| echo | |
| read -p "Delete ${#valid_branches[@]} local branch(es)? [y/N]: " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| log_warning "Skipping local branch deletion" | |
| return | |
| fi | |
| local deleted_count=0 | |
| for branch in "${valid_branches[@]}"; do | |
| # Double-check branch still exists | |
| if ! branch_exists_locally "$branch"; then | |
| log_warning "Branch '$branch' was deleted externally, skipping" | |
| continue | |
| fi | |
| if git branch -d "$branch" 2>/dev/null; then | |
| log_success "Deleted local branch: $branch" | |
| ((deleted_count++)) | |
| elif git branch -D "$branch" 2>/dev/null; then | |
| log_warning "Force deleted local branch: $branch" | |
| ((deleted_count++)) | |
| else | |
| log_error "Failed to delete local branch: $branch" | |
| fi | |
| done | |
| log_info "Successfully deleted $deleted_count local branch(es)" | |
| } | |
| # Delete remote branches | |
| delete_remote_branches() { | |
| local branches=("$@") | |
| local valid_branches=() | |
| if [[ ${#branches[@]} -eq 0 ]]; then | |
| log_info "No remote branches to delete" | |
| return | |
| fi | |
| # Validate branches exist | |
| for branch in "${branches[@]}"; do | |
| if branch_exists_remotely "$branch"; then | |
| valid_branches+=("$branch") | |
| else | |
| log_warning "Remote branch '${REMOTE}/$branch' no longer exists, skipping" | |
| fi | |
| done | |
| if [[ ${#valid_branches[@]} -eq 0 ]]; then | |
| log_info "No valid remote branches to delete" | |
| return | |
| fi | |
| log_info "Remote branches to delete:" | |
| for branch in "${valid_branches[@]}"; do | |
| echo " - ${REMOTE}/$branch" | |
| done | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_dry_run "Would delete ${#valid_branches[@]} remote branch(es)" | |
| return | |
| fi | |
| echo | |
| read -p "Delete ${#valid_branches[@]} remote branch(es)? [y/N]: " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| log_warning "Skipping remote branch deletion" | |
| return | |
| fi | |
| local deleted_count=0 | |
| for branch in "${valid_branches[@]}"; do | |
| # Double-check branch still exists | |
| if ! branch_exists_remotely "$branch"; then | |
| log_warning "Remote branch '${REMOTE}/$branch' was deleted externally, skipping" | |
| continue | |
| fi | |
| if git push "$REMOTE" --delete "$branch" --no-verify 2>/dev/null; then | |
| log_success "Deleted remote branch: ${REMOTE}/$branch" | |
| ((deleted_count++)) | |
| else | |
| local exit_code=$? | |
| case $exit_code in | |
| 1) log_error "Failed to delete remote branch '${REMOTE}/$branch' - permission denied or branch protected" ;; | |
| 128) log_error "Failed to delete remote branch '${REMOTE}/$branch' - remote not found or network error" ;; | |
| *) log_error "Failed to delete remote branch '${REMOTE}/$branch' (exit code: $exit_code)" ;; | |
| esac | |
| fi | |
| done | |
| log_info "Successfully deleted $deleted_count remote branch(es)" | |
| } | |
| # Parse command line arguments | |
| parse_arguments() { | |
| local LOCAL_ONLY=false | |
| local REMOTE_ONLY=false | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| -d|--dry-run) | |
| DRY_RUN=true | |
| shift | |
| ;; | |
| -x|--execute) | |
| DRY_RUN=false | |
| shift | |
| ;; | |
| -r|--remote) | |
| if [[ -z "$2" ]] || [[ "$2" =~ ^- ]]; then | |
| log_error "Remote name required for --remote option" | |
| exit 1 | |
| fi | |
| # Validate remote name format (basic Git remote name validation) | |
| if [[ ! "$2" =~ ^[a-zA-Z0-9._-]+$ ]]; then | |
| log_error "Invalid remote name format: '$2'" | |
| exit 1 | |
| fi | |
| REMOTE="$2" | |
| shift 2 | |
| ;; | |
| -m|--mode) | |
| case "$2" in | |
| merged|stale|both) | |
| CLEANUP_MODE="$2" | |
| shift 2 | |
| ;; | |
| *) | |
| log_error "Invalid mode: $2. Use: merged, stale, or both" | |
| exit 1 | |
| ;; | |
| esac | |
| ;; | |
| -s|--stale-days) | |
| if [[ "$2" =~ ^[0-9]+$ ]] && [[ "$2" -gt 0 ]]; then | |
| STALE_DAYS="$2" | |
| shift 2 | |
| else | |
| log_error "Stale days must be a positive integer" | |
| exit 1 | |
| fi | |
| ;; | |
| -t|--test-pattern) | |
| # Test pattern matching (for debugging) | |
| if [[ -z "$2" ]] || [[ "$2" =~ ^- ]]; then | |
| log_error "Branch name required for --test-pattern option" | |
| exit 1 | |
| fi | |
| local test_branch="$2" | |
| # Sanitize test branch name | |
| test_branch=$(echo "$test_branch" | tr -d '\000-\037\177') | |
| log_info "Testing pattern matching for branch: '$test_branch'" | |
| if is_protected_branch "$test_branch"; then | |
| log_success "Branch '$test_branch' would be PROTECTED" | |
| else | |
| log_warning "Branch '$test_branch' would be DELETED" | |
| fi | |
| exit 0 | |
| ;; | |
| -i|--ignore) | |
| if [[ -z "$2" ]] || [[ "$2" =~ ^- ]]; then | |
| log_error "Branch pattern required for --ignore option" | |
| exit 1 | |
| fi | |
| # Validate pattern is not empty and doesn't contain dangerous characters | |
| if [[ ${#2} -gt 200 ]]; then | |
| log_error "Branch pattern too long (max 200 chars): '${2:0:50}...'" | |
| exit 1 | |
| fi | |
| # Check for potentially dangerous regex patterns | |
| if validate_regex_pattern "$2"; then | |
| PROTECTED_BRANCHES+=("$2") | |
| log_verbose "Added protection pattern: '$2'" | |
| else | |
| log_error "Unsafe or invalid protection pattern: '$2'" | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| -v|--verbose) | |
| VERBOSE=true | |
| shift | |
| ;; | |
| -l|--local-only) | |
| LOCAL_ONLY=true | |
| shift | |
| ;; | |
| -R|--remote-only) | |
| REMOTE_ONLY=true | |
| shift | |
| ;; | |
| *) | |
| log_error "Unknown option: $1" | |
| usage | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # Export flags for use by main function | |
| export LOCAL_ONLY REMOTE_ONLY | |
| } | |
| # Main function | |
| main() { | |
| parse_arguments "$@" | |
| log_info "Git Branch Cleanup Script" | |
| log_info "=========================" | |
| echo | |
| # Check prerequisites | |
| check_system_requirements | |
| check_git_repo | |
| check_remote | |
| # Show configuration | |
| log_info "Configuration:" | |
| echo " Dry Run: $DRY_RUN" | |
| echo " Remote: $REMOTE" | |
| echo " Cleanup Mode: $CLEANUP_MODE" | |
| if [[ "$CLEANUP_MODE" == "stale" ]] || [[ "$CLEANUP_MODE" == "both" ]]; then | |
| echo " Stale Days: $STALE_DAYS" | |
| fi | |
| echo " Protected Patterns: ${PROTECTED_BRANCHES[*]}" | |
| echo " Local Only: ${LOCAL_ONLY:-false}" | |
| echo " Remote Only: ${REMOTE_ONLY:-false}" | |
| echo | |
| # Fetch updates | |
| fetch_updates | |
| echo | |
| # Get branches to delete based on cleanup mode | |
| local merged_local=() | |
| local merged_remote=() | |
| local stale_local=() | |
| local stale_remote=() | |
| local final_local=() | |
| local final_remote=() | |
| if [[ "${REMOTE_ONLY:-false}" != true ]]; then | |
| if [[ "$CLEANUP_MODE" == "merged" ]] || [[ "$CLEANUP_MODE" == "both" ]]; then | |
| log_info "Scanning for merged local branches..." | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && merged_local+=("$branch") | |
| done < <(get_merged_local_branches) | |
| fi | |
| if [[ "$CLEANUP_MODE" == "stale" ]] || [[ "$CLEANUP_MODE" == "both" ]]; then | |
| log_info "Scanning for stale local branches (older than ${STALE_DAYS} days)..." | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && stale_local+=("$branch") | |
| done < <(get_stale_local_branches) | |
| fi | |
| fi | |
| if [[ "${LOCAL_ONLY:-false}" != true ]]; then | |
| if [[ "$CLEANUP_MODE" == "merged" ]] || [[ "$CLEANUP_MODE" == "both" ]]; then | |
| log_info "Scanning for merged remote branches..." | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && merged_remote+=("$branch") | |
| done < <(get_merged_remote_branches) | |
| fi | |
| if [[ "$CLEANUP_MODE" == "stale" ]] || [[ "$CLEANUP_MODE" == "both" ]]; then | |
| log_info "Scanning for stale remote branches (older than ${STALE_DAYS} days)..." | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && stale_remote+=("$branch") | |
| done < <(get_stale_remote_branches) | |
| fi | |
| fi | |
| # Combine and deduplicate branches based on mode | |
| if [[ "$CLEANUP_MODE" == "merged" ]]; then | |
| final_local=("${merged_local[@]}") | |
| final_remote=("${merged_remote[@]}") | |
| elif [[ "$CLEANUP_MODE" == "stale" ]]; then | |
| final_local=("${stale_local[@]}") | |
| final_remote=("${stale_remote[@]}") | |
| elif [[ "$CLEANUP_MODE" == "both" ]]; then | |
| # Combine and deduplicate | |
| local all_local=("${merged_local[@]}" "${stale_local[@]}") | |
| local all_remote=("${merged_remote[@]}" "${stale_remote[@]}") | |
| # Deduplicate local branches | |
| if [[ ${#all_local[@]} -gt 0 ]]; then | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && final_local+=("$branch") | |
| done < <(printf '%s\n' "${all_local[@]}" | sort -u) | |
| fi | |
| # Deduplicate remote branches | |
| if [[ ${#all_remote[@]} -gt 0 ]]; then | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && final_remote+=("$branch") | |
| done < <(printf '%s\n' "${all_remote[@]}" | sort -u) | |
| fi | |
| fi | |
| # Show summary | |
| log_info "Summary:" | |
| if [[ "$CLEANUP_MODE" == "merged" ]]; then | |
| echo " Merged local branches: ${#final_local[@]}" | |
| echo " Merged remote branches: ${#final_remote[@]}" | |
| elif [[ "$CLEANUP_MODE" == "stale" ]]; then | |
| echo " Stale local branches (${STALE_DAYS}+ days): ${#final_local[@]}" | |
| echo " Stale remote branches (${STALE_DAYS}+ days): ${#final_remote[@]}" | |
| elif [[ "$CLEANUP_MODE" == "both" ]]; then | |
| echo " Local branches (merged OR ${STALE_DAYS}+ days old): ${#final_local[@]}" | |
| echo " Remote branches (merged OR ${STALE_DAYS}+ days old): ${#final_remote[@]}" | |
| if [[ "$VERBOSE" == true ]]; then | |
| echo " - Merged local: ${#merged_local[@]}, Stale local: ${#stale_local[@]}" | |
| echo " - Merged remote: ${#merged_remote[@]}, Stale remote: ${#stale_remote[@]}" | |
| fi | |
| fi | |
| echo | |
| # Perform cleanup | |
| if [[ "${REMOTE_ONLY:-false}" != true ]]; then | |
| delete_local_branches "${final_local[@]}" | |
| echo | |
| fi | |
| if [[ "${LOCAL_ONLY:-false}" != true ]]; then | |
| delete_remote_branches "${final_remote[@]}" | |
| echo | |
| fi | |
| # Final message | |
| if [[ "$DRY_RUN" == true ]]; then | |
| log_info "Dry run completed. Use --execute to perform actual deletions." | |
| else | |
| log_success "Branch cleanup completed!" | |
| log_info "Consider running 'git gc' to clean up unreferenced objects." | |
| fi | |
| } | |
| # Run main function with all arguments | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment