Skip to content

Instantly share code, notes, and snippets.

@nelson-ens
Last active July 31, 2025 19:23
Show Gist options
  • Select an option

  • Save nelson-ens/34db9ad1b64ed5ab1ed720e0b78a4b97 to your computer and use it in GitHub Desktop.

Select an option

Save nelson-ens/34db9ad1b64ed5ab1ed720e0b78a4b97 to your computer and use it in GitHub Desktop.
#!/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