Skip to content

Instantly share code, notes, and snippets.

@JarJak
Last active August 6, 2025 13:14
Show Gist options
  • Save JarJak/19426e9d0fcc4d007a53dd9267074fd3 to your computer and use it in GitHub Desktop.
Save JarJak/19426e9d0fcc4d007a53dd9267074fd3 to your computer and use it in GitHub Desktop.
Bash script to delete all remote git branches older than provided days (runs in dry mode by default)
#!/bin/bash
# This script identifies and suggests deletion of remote Git branches
# that haven't been updated in a specified number of days.
usage() {
echo "Usage: $0 <stale_days> [--force] [--path=<repo_path>]"
echo " <stale_days>: REQUIRED. The number of days after which a branch is considered stale."
echo " --force: Optional. Execute the deletion commands. By default, a dry run is performed."
echo " --path=<repo_path>: Optional. Specify the path to the local Git repository (defaults to current directory)."
exit 1
}
STALE_DAYS=""
DRY_RUN=true
FORCE_DELETE=false
REPO_PATH="."
# Check if at least one argument (stale_days) is provided
if [ -z "$1" ]; then
usage
fi
# The first argument is always STALE_DAYS
STALE_DAYS="$1"
# Validate STALE_DAYS is a positive integer
if ! [[ "$STALE_DAYS" =~ ^[0-9]+$ ]] || [ "$STALE_DAYS" -le 0 ]; then
echo "Error: <stale_days> must be a positive integer."
usage
fi
# Shift the first argument (STALE_DAYS) out, then parse remaining optional arguments
shift
for arg in "$@"; do
case "$arg" in
--force)
FORCE_DELETE=true
DRY_RUN=false
;;
--path=*)
REPO_PATH="${arg#*=}"
;;
*)
echo "Error: Unknown argument '$arg'"
usage
;;
esac
done
echo "Analyzing repository: $REPO_PATH"
if [ "$DRY_RUN" = true ]; then
echo "Running in DRY RUN mode. No branches will be deleted."
else
echo "Running in FORCE mode. Branches will be deleted."
fi
echo "---------------------------------------------------------------------"
echo "Fetching the latest remote branch information and syncing with local branches..."
# Fetch all remote branches to ensure we have the most up-to-date information
# Use -C to specify the repository path
git -C "$REPO_PATH" fetch --prune
echo ""
echo "Calculating age for all remote branches (excluding main/master/1.x)..."
echo "---------------------------------------------------------------------"
branches_to_delete=()
OLDEST_STALE_BRANCH_NAME=""
NEWEST_STALE_BRANCH_NAME=""
OLDEST_STALE_TIMESTAMP=0 # Initialize with a very old timestamp
NEWEST_STALE_TIMESTAMP=$(date +%s) # Initialize with current timestamp
# Array to store all branch info for sorting: "age_days|commit_date|branch_name"
declare -a ALL_BRANCH_AGES=()
current_timestamp=$(date +%s)
# Create a temporary file to store branch data to avoid subshell issues
TEMP_BRANCH_FILE=$(mktemp)
if [ -z "$TEMP_BRANCH_FILE" ]; then
echo "Error: Could not create temporary file."
exit 1
fi
# Populate the temporary file with branch data
git -C "$REPO_PATH" for-each-ref --format='%(committerdate:unix) %(committerdate:format:%Y-%m-%d) %(refname:short)' refs/remotes/origin/ > "$TEMP_BRANCH_FILE"
# Loop through the temporary file to process branch data
while read commit_timestamp_raw commit_date branch; do
# Convert commit_timestamp_raw to integer
commit_timestamp=$((commit_timestamp_raw))
# Calculate difference in seconds, then convert to days
age_seconds=$((current_timestamp - commit_timestamp))
age_days=$((age_seconds / (60 * 60 * 24)))
# Exclude HEAD and master/main/1.x branches from being considered stale or listed here
if [[ "$branch" != "origin/HEAD" && "$branch" != "origin/master" && "$branch" != "origin/main" && "$branch" != "origin/1.x" ]]; then
# Store info for sorting: "age_days|commit_date|branch_name"
ALL_BRANCH_AGES+=("$age_days|$commit_date|${branch#origin/}")
if (( age_days > STALE_DAYS )); then
# Add the branch name (without 'origin/') to the array
branches_to_delete+=("${branch#origin/}")
if [ -z "$OLDEST_STALE_BRANCH_NAME" ] || (( commit_timestamp < OLDEST_STALE_TIMESTAMP )); then
OLDEST_STALE_TIMESTAMP=$commit_timestamp
OLDEST_STALE_BRANCH_NAME="${branch#origin/}"
fi
if [ -z "$NEWEST_STALE_BRANCH_NAME" ] || (( commit_timestamp > NEWEST_STALE_TIMESTAMP )); then
NEWEST_STALE_TIMESTAMP=$commit_timestamp
NEWEST_STALE_BRANCH_NAME="${branch#origin/}"
fi
fi
fi
done < "$TEMP_BRANCH_FILE"
# Clean up the temporary file
rm "$TEMP_BRANCH_FILE"
# Sort the ALL_BRANCH_AGES array by age (numeric sort on the first field)
# Using printf "%s\n" "${ALL_BRANCH_AGES[@]}" to handle potential newlines in branch names,
# then piping to sort.
IFS=$'\n' sorted_branches=($(printf "%s\n" "${ALL_BRANCH_AGES[@]}" | sort -n -t'|' -k1))
unset IFS
# Print the sorted list of all branches
for branch_info in "${sorted_branches[@]}"; do
# Split the string back into components using IFS
IFS='|' read -r age_days commit_date branch_name <<< "$branch_info"
echo " Branch: $branch_name (Last commit: $commit_date, Age: $age_days days)"
done
echo "---------------------------------------------------------------------"
if [ ${#branches_to_delete[@]} -eq 0 ]; then
echo "No stale remote branches found older than $STALE_DAYS days in $REPO_PATH."
else
echo ""
echo "Summary of stale branches in $REPO_PATH (older than $STALE_DAYS days):"
echo " Number of branches to delete: ${#branches_to_delete[@]}"
if [ -n "$NEWEST_STALE_BRANCH_NAME" ]; then
# Use date -r for BSD date compatibility
echo " Newest stale branch: $NEWEST_STALE_BRANCH_NAME (Last updated: $(date -r "$NEWEST_STALE_TIMESTAMP" +%Y-%m-%d))"
fi
if [ -n "$OLDEST_STALE_BRANCH_NAME" ]; then
# Use date -r for BSD date compatibility
echo " Oldest stale branch: $OLDEST_STALE_BRANCH_NAME (Last updated: $(date -r "$OLDEST_STALE_TIMESTAMP" +%Y-%m-%d))"
fi
echo ""
if [ "$FORCE_DELETE" = true ]; then
echo "Deleting the identified stale branches..."
# Execute the deletion commands
for branch in "${branches_to_delete[@]}"; do
echo "Executing: git push origin --delete $branch"
git -C "$REPO_PATH" push origin --delete "$branch"
done
echo ""
echo "Deletion process complete."
else
echo "To delete these branches, run the script with the --force option:"
echo ""
echo "Example: $0 $STALE_DAYS --force --path=\"$REPO_PATH\""
echo ""
echo "Please review the summary carefully before executing the commands."
fi
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment