Skip to content

Instantly share code, notes, and snippets.

@mecograph
Last active July 15, 2026 12:44
Show Gist options
  • Select an option

  • Save mecograph/c41da87b350b11e2ed1b8814e4070296 to your computer and use it in GitHub Desktop.

Select an option

Save mecograph/c41da87b350b11e2ed1b8814e4070296 to your computer and use it in GitHub Desktop.
A script for conveniently cleaning up both local and remote branches, helping to keep the working directory organized and uncluttered
#!/bin/bash
# Define ANSI color codes
green="\033[32m"
bright_black="\033[90m"
red="\033[31m"
blue="\033[34m"
reset="\033[0m"
# Branches that must never be deleted, locally or on the remote
# (the default branch and merge target are additionally protected dynamically)
protected_branches="{ADD YOUR BRANCH NAMES HERE}"
cb_is_protected() {
local branch=$1 p
for p in $protected_branches; do
[ "$branch" = "$p" ] && return 0
done
return 1
}
# Utility Functions
format_repo_name() {
local repo_name=${1//[-\/]/ }
echo $repo_name | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2));}1'
}
capitalize() {
echo "$1" | awk '{ for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2)); }1'
}
clear_lines() {
local count=$1
while [ $count -gt 0 ]; do
echo -en "\033[1A\033[2K"
((count--))
done
}
get_formatted_date() {
local branch=$1
local reflog_output
# Use --date=iso for consistent parsing across platforms
# Oldest reflog entry = branch creation; entries older than the reflog
# expiry (default 90 days) are gone, hence the fallback text
if ! reflog_output=$(git reflog --date=iso "$branch" 2>/dev/null | sed -n '$p'); then
echo "more than 90 days ago"
return
fi
if [[ -z "$reflog_output" ]]; then
echo "more than 90 days ago"
return
fi
# Extract ISO date (format: 2025-12-10 14:02:56 +0100)
local iso_date=$(echo "$reflog_output" | sed -n 's/.*@{\([0-9-]* [0-9:]*\).*/\1/p')
if [[ -z "$iso_date" ]]; then
echo "more than 90 days ago"
return
fi
# Extract just the date part (YYYY-MM-DD)
local date_only=$(echo "$iso_date" | cut -d' ' -f1)
# Platform-independent date formatting (macOS, Linux, Windows Git Bash)
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
# Windows Git Bash - uses GNU date
date -d "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago"
elif date --version &>/dev/null 2>&1; then
# GNU date (Linux)
date -d "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago"
else
# BSD date (macOS)
date -jf "%Y-%m-%d" "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago"
fi
}
# List local branches, excluding the given branches (exact match) and any
# branch checked out in a linked worktree (git refuses to delete those;
# they get their own cleanup step later)
cb_list_branches() {
local top wt_branches branch skip excl
top=$(git rev-parse --show-toplevel 2>/dev/null)
wt_branches=$(git worktree list --porcelain 2>/dev/null | awk -v top="$top" '
/^worktree /{path=substr($0,10)}
/^branch refs\/heads\//{if(path!=top) print substr($0,19)}')
git for-each-ref refs/heads --format='%(refname:short)' 2>/dev/null | while read -r branch; do
cb_is_protected "$branch" && continue
skip=""
for excl; do
[ "$branch" = "$excl" ] && skip=1 && break
done
[ -n "$skip" ] && continue
if [ -n "$wt_branches" ] && echo "$wt_branches" | grep -Fxq "$branch"; then
continue
fi
echo "$branch"
done
}
# Is <branch> merged into <target>? Detects all three merge styles:
# merge commit / fast-forward (ancestry), rebase merge (patch-equivalence),
# and squash merge (merging the branch into target would change nothing)
cb_is_merged() {
local branch=$1 target=$2
local cherry merged_tree
if git merge-base --is-ancestor "$branch" "$target" 2>/dev/null; then
return 0
fi
cherry=$(git cherry "$target" "$branch" 2>/dev/null)
if [ -n "$cherry" ] && ! echo "$cherry" | grep -q '^+'; then
return 0
fi
# merge-tree --write-tree needs git >= 2.38; silently skipped on older git
if merged_tree=$(git merge-tree --write-tree "$target" "$branch" 2>/dev/null); then
if [ -n "$merged_tree" ] && [ "$merged_tree" = "$(git rev-parse "$target^{tree}" 2>/dev/null)" ]; then
return 0
fi
fi
return 1
}
# Remote state of <branch>:
# tracked - upstream configured and remote-tracking ref exists
# gone - upstream configured but remote branch was deleted
# untracked - no upstream, but origin/<branch> exists by name
# none - no trace of a remote (may still have been squash/rebase-merged
# and deleted; tracking info is simply absent)
cb_remote_state() {
local branch=$1
local upstream
upstream=$(git for-each-ref --format='%(upstream:short)' "refs/heads/$branch" 2>/dev/null)
if [ -n "$upstream" ]; then
if git show-ref --verify --quiet "refs/remotes/$upstream"; then
echo "tracked"
else
echo "gone"
fi
elif git show-ref --verify --quiet "refs/remotes/origin/$branch"; then
echo "untracked"
else
echo "none"
fi
}
# Safe delete with force fallback; surfaces git's reason on failure and
# records force deletions (relevant for the deep-cleanup warning at the end).
# When $2 is non-empty the script's own merge detection already confirmed the
# branch as merged (incl. squash/rebase merges, which git branch -d cannot
# see), so -D is used directly without the contradictory force prompt.
delete_local_branch() {
local branch=$1
local merged=${2:-}
local del_err force_confirm
if cb_is_protected "$branch"; then
echo -e "${red}Branch $branch is protected and cannot be deleted${reset}"
return 1
fi
if git branch -d "$branch" 2>/dev/null; then
echo "Deleted local branch $branch"
return 0
fi
if [ -n "$merged" ]; then
if del_err=$(git branch -D "$branch" 2>&1); then
echo "Deleted local branch $branch"
return 0
fi
echo -e "${red}Failed to delete local branch $branch${reset}"
echo "$del_err" | sed -n '1p'
return 1
fi
echo -en "${red}Branch not fully merged. Force delete? (y/n)${reset} "
read force_confirm
if [[ $force_confirm =~ ^[Yy]$ ]]; then
if del_err=$(git branch -D "$branch" 2>&1); then
force_deleted_any=1
echo "Deleted local branch $branch"
return 0
fi
echo -e "${red}Failed to delete local branch $branch${reset}"
echo "$del_err" | sed -n '1p'
fi
return 1
}
# Delete the remote branch behind <remote_ref> (e.g. origin/feature/x);
# derives remote name and branch name from the ref instead of assuming origin
delete_remote_branch() {
local branch=$1
local remote_ref=$2
local push_err
if [ -z "$remote_ref" ]; then
echo "No remote branch for $branch"
return 1
fi
if cb_is_protected "${remote_ref#*/}"; then
echo -e "${red}Branch ${remote_ref#*/} is protected and cannot be deleted${reset}"
return 1
fi
if push_err=$(git push "${remote_ref%%/*}" --delete "${remote_ref#*/}" 2>&1); then
echo "Deleted remote branch ${remote_ref#*/}"
return 0
fi
echo -e "${red}Failed to delete remote branch ${remote_ref#*/}${reset}"
echo "$push_err" | sed -n '$p'
return 1
}
handle_worktree_deletion() {
local choice=$1
local wt_path=$2
local wt_branch=$3
local merged=${4:-}
local confirm_choice force_confirm wt_err
if [[ $choice == "skip" ]]; then
return
fi
read -p "you selected $choice. Are you sure? (y/n) " confirm_choice
if [[ ! $confirm_choice =~ ^[Yy]$ ]]; then
return
fi
if wt_err=$(git worktree remove "$wt_path" 2>&1); then
echo "Removed worktree $wt_path"
else
echo "$wt_err" | sed -n '1p'
echo -en "${red}Force remove worktree? (y/n)${reset} "
read force_confirm
if [[ ! $force_confirm =~ ^[Yy]$ ]]; then
return
fi
if wt_err=$(git worktree remove --force "$wt_path" 2>&1); then
echo "Removed worktree $wt_path"
else
echo -e "${red}Failed to remove worktree $wt_path${reset}"
echo "$wt_err" | sed -n '1p'
return
fi
fi
if [[ $choice == "worktree + branch" && -n $wt_branch ]]; then
delete_local_branch "$wt_branch" "$merged"
fi
}
handle_branch_deletion() {
local choice=$1
local branch=$2
local remote=$3
local current_branch=$4
local merged=${5:-}
if [[ $choice == "skip" ]]; then
return
fi
if cb_is_protected "$branch"; then
echo -e "${red}Branch $branch is protected and cannot be deleted${reset}"
return
fi
# Prevent deletion of current branch (if not in detached HEAD state)
if [[ -n "$current_branch" && "$branch" == "$current_branch" ]]; then
echo -e "${red}Cannot delete currently checked-out branch${reset}"
return
fi
read -p "you selected $choice. Are you sure? (y/n) " confirm_choice
if [[ ! $confirm_choice =~ ^[Yy]$ ]]; then
return
fi
case $choice in
"local branch")
delete_local_branch "$branch" "$merged"
;;
"remote branch")
delete_remote_branch "$branch" "$remote"
;;
both)
local local_deleted=false
local remote_deleted=false
local push_err
# Try safe delete first
if git branch -d "$branch" 2>/dev/null; then
local_deleted=true
elif [ -n "$merged" ]; then
# Merged per our detection (git -d can't see squash/rebase
# merges) - force delete without the contradictory prompt
if git branch -D "$branch" 2>/dev/null; then
local_deleted=true
fi
else
echo -en "${red}Branch not fully merged. Force delete? (y/n)${reset} "
read force_confirm
if [[ $force_confirm =~ ^[Yy]$ ]]; then
if git branch -D "$branch" 2>/dev/null; then
local_deleted=true
force_deleted_any=1
fi
fi
fi
if [ -n "$remote" ]; then
if push_err=$(git push "${remote%%/*}" --delete "${remote#*/}" 2>&1); then
remote_deleted=true
else
echo "$push_err" | sed -n '$p'
fi
fi
if $local_deleted && $remote_deleted; then
echo "Deleted both local and remote branches for $branch"
elif $local_deleted; then
echo "Deleted local branch $branch (no remote branch found)"
elif $remote_deleted; then
echo "Deleted remote branch $branch (failed to delete local)"
else
echo -e "${red}Failed to delete branch $branch${reset}"
fi
;;
*)
echo "Invalid choice. Skipping $branch"
;;
esac
}
select_option() {
# little helpers for terminal print control and key input
ESC=$( printf "\033")
cursor_blink_on() { printf "$ESC[?25h"; }
cursor_blink_off() { printf "$ESC[?25l"; }
cursor_to() { printf "$ESC[$1;${2:-1}H"; }
print_option() { printf " $1 "; }
print_selected() {
# ANSI Red color start
local red="\033[31m"
local blue="\033[34m"
# ANSI Color reset (to return to default after printing)
local reset="\033[0m"
printf "${blue} β–Έ ${red}$1${reset} "
}
get_cursor_row() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#*[}; }
key_input() { read -s -n3 key 2>/dev/null >&2
if [[ $key = $ESC[A ]]; then echo up; fi
if [[ $key = $ESC[B ]]; then echo down; fi
if [[ $key = "" ]]; then echo enter; fi; }
# initially print empty new lines (scroll down if at bottom of screen)
for opt; do printf "\n"; done
# determine current screen position for overwriting the options
local lastrow=`get_cursor_row`
local startrow=$(($lastrow - $#))
# ensure cursor and input echoing back on upon a ctrl+c during read -s
trap "cursor_blink_on; stty echo; printf '\n'; exit" 2
cursor_blink_off
local selected=0
while true; do
# print options by overwriting the last lines
local idx=0
for opt; do
cursor_to $(($startrow + $idx))
if [ $idx -eq $selected ]; then
print_selected "$opt"
else
print_option "$opt"
fi
((idx++))
done
# user key control
case `key_input` in
enter) break;;
up) ((selected--));
if [ $selected -lt 0 ]; then selected=$(($# - 1)); fi;;
down) ((selected++));
if [ $selected -ge $# ]; then selected=0; fi;;
esac
done
# cursor position back to normal
cursor_to $lastrow
printf "\n"
cursor_blink_on
return $selected
}
draw_screen() {
clear
echo -e "\n✨ Starting branch cleanup for $formatted_name..."
echo -e "πŸ”Ž Found $local_branch_count local branches\n"
for info in "${branch_info[@]}"; do
echo -e "$info"
done
}
# Test mode: stop after function definitions so a test harness can
# source this file without triggering the interactive flow
if [ -n "${CLEANUP_BRANCHES_TEST:-}" ]; then
return 0 2>/dev/null || exit 0
fi
# Main
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <target_git_repo_directory>"
exit 1
fi
repo_path="$1"
# Validate directory exists
if [ ! -d "$repo_path" ]; then
echo "βœ‹ Directory does not exist: $repo_path"
exit 1
fi
cd "$repo_path" || exit 1
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
echo "βœ‹ Not a git repo, exiting."
exit 1
fi
clear
formatted_name=$(format_repo_name "$repo_path")
formatted_name=$(capitalize "$formatted_name")
echo -e "\n✨ Starting branch cleanup for $formatted_name..."
echo -e "πŸ”„ Fetching latest remote information..."
# Fetch latest remote information to ensure accurate branch status
if git fetch --prune 2>/dev/null; then
echo -e "βœ… Remote information updated"
else
echo -e "⚠️ Warning: Failed to fetch remote updates, continuing with local information"
fi
# Determine the default branch (master or main)
default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
if [ -z "$default_branch" ]; then
# Fallback: try to detect main or master
if git show-ref --verify --quiet refs/heads/main; then
default_branch="main"
elif git show-ref --verify --quiet refs/heads/master; then
default_branch="master"
else
echo "βœ‹ Cannot determine default branch (neither main nor master found)"
exit 1
fi
fi
# Determine the merge target for "merged" checks: prefer origin/{DEFAULT_BRANCHNAME}
# (integration branch workflow), fall back to the default branch
if git show-ref --verify --quiet refs/remotes/origin/{DEFAULT_BRANCHNAME}; then
merge_target="origin/{DEFAULT_BRANCHNAME}"
merge_target_local="{DEFAULT_BRANCHNAME}"
elif git show-ref --verify --quiet "refs/remotes/origin/$default_branch"; then
merge_target="origin/$default_branch"
merge_target_local="$default_branch"
else
merge_target="$default_branch"
merge_target_local="$default_branch"
fi
# Get current branch to prevent deletion (handle detached HEAD)
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ "$current_branch" == "HEAD" ]]; then
# In detached HEAD state - no branch is currently checked out
current_branch=""
fi
# Track whether any branch was force-deleted (warned about before deep cleanup)
force_deleted_any=""
# Get all local branches except the default/integration branches and
# branches checked out in linked worktrees (handled in the worktree step)
local_branches_array=()
while IFS= read -r line; do
[ -n "$line" ] && local_branches_array[${#local_branches_array[@]}]=$line
done <<< "$(cb_list_branches "$default_branch" "$merge_target_local")"
local_branch_count="${#local_branches_array[@]}"
# No exit here: with zero branches the loop below is a no-op and the
# worktree cleanup step still gets its chance
if [ "$local_branch_count" -eq 0 ]; then
echo -e "βœ… No branches to clean up!"
else
echo -e "πŸ”Ž Found $local_branch_count local branches\n"
fi
current_branch_num=0
# Iterate over the local branches
for branch_name in "${local_branches_array[@]}"; do
# Skip empty lines
[ -z "$branch_name" ] && continue
branch_info=()
current_branch_num=$((current_branch_num + 1))
# Remote state and the remote ref usable for deletion (upstream if
# configured, otherwise origin/<branch> when it exists by name)
remote_state=$(cb_remote_state "$branch_name")
remote=""
case $remote_state in
tracked|gone)
remote=$(git for-each-ref --format='%(upstream:short)' "refs/heads/$branch_name" 2>/dev/null)
;;
untracked)
remote="origin/$branch_name"
;;
esac
# Calculate commits behind/ahead, both against the merge target
behind="0"
ahead="0"
if git merge-base "$branch_name" "$merge_target" &>/dev/null; then
behind=$(git rev-list --count "$branch_name..$merge_target" 2>/dev/null)
behind=${behind:-0} # Fallback if empty
ahead=$(git rev-list --count "$merge_target..$branch_name" 2>/dev/null)
ahead=${ahead:-0} # Fallback if empty
fi
formatted_date=$(get_formatted_date "$branch_name")
# Check if branch is already merged (detects merge commits, rebase
# merges and squash merges)
is_merged=""
branch_merged=""
if cb_is_merged "$branch_name" "$merge_target"; then
branch_merged=1
is_merged="${green}βœ“ merged${bright_black} | "
fi
# Check remote status and provide detailed information
case $remote_state in
gone)
# Upstream configured but remote branch was deleted
if [ -n "$branch_merged" ]; then
remote_status="${green}βœ“ remote merged & deleted${reset}"
else
remote_status="${red}⚠ remote deleted unmerged${reset}"
fi
;;
tracked|untracked)
remote_status="${bright_black}(has remote: $remote)${reset}"
;;
*)
# No trace of a remote (note: a squash/rebase-merged branch whose
# remote is long gone can land here too if tracking was never set)
remote_status="${bright_black}(local only)${reset}"
;;
esac
# Mark current branch (only if not in detached HEAD state)
if [[ -n "$current_branch" && "$branch_name" == "$current_branch" ]]; then
remote_status="${remote_status} ${blue}(current)${reset}"
fi
# Accumulate branch details
branch_detail="Branch ($current_branch_num/$local_branch_count): ${green}$branch_name${reset}"
action_detail="${is_merged}${bright_black}Created $formatted_date | behind by $behind | ahead by $ahead${reset}"
status_detail="$remote_status"
branch_info+=("$branch_detail\n$action_detail\n$status_detail")
# Redraw the screen with updated information after each action
draw_screen
# Build options dynamically based on remote status
case $remote_state in
tracked|untracked)
# Branch has an existing remote - show all options
options=("skip" "local branch" "remote branch" "both")
;;
*)
# Remote gone or never existed - only local delete available
options=("skip" "local branch")
;;
esac
echo "Delete?"
select_option "${options[@]}"
selected_option=$?
selected_text=${options[$selected_option]}
handle_branch_deletion "$selected_text" "$branch_name" "$remote" "$current_branch" "$branch_merged"
done
# Worktree cleanup: offer removal of linked worktrees (and their branches).
# Branches checked out in worktrees were excluded from the loop above since
# git refuses to delete them while the worktree exists.
worktree_paths=()
worktree_branches=()
repo_toplevel=$(git rev-parse --show-toplevel 2>/dev/null)
wt_path=""
wt_branch=""
while IFS= read -r line; do
case $line in
"worktree "*) wt_path=${line#worktree } ;;
"branch refs/heads/"*) wt_branch=${line#branch refs/heads/} ;;
"")
if [ -n "$wt_path" ] && [ "$wt_path" != "$repo_toplevel" ]; then
worktree_paths[${#worktree_paths[@]}]=$wt_path
worktree_branches[${#worktree_branches[@]}]=$wt_branch
fi
wt_path=""
wt_branch=""
;;
esac
done <<< "$(git worktree list --porcelain 2>/dev/null)"
# Flush the last stanza (command substitution strips the trailing blank line)
if [ -n "$wt_path" ] && [ "$wt_path" != "$repo_toplevel" ]; then
worktree_paths[${#worktree_paths[@]}]=$wt_path
worktree_branches[${#worktree_branches[@]}]=$wt_branch
fi
worktree_count="${#worktree_paths[@]}"
if [ "$worktree_count" -gt 0 ]; then
echo -e "\n🌳 Found $worktree_count linked worktrees\n"
wt_idx=0
while [ $wt_idx -lt $worktree_count ]; do
wt_path=${worktree_paths[$wt_idx]}
wt_branch=${worktree_branches[$wt_idx]}
wt_idx=$((wt_idx + 1))
wt_state="clean"
if [ -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)" ]; then
wt_state="${red}uncommitted changes${bright_black}"
fi
is_merged=""
wt_merged=""
if [ -n "$wt_branch" ] && cb_is_merged "$wt_branch" "$merge_target"; then
wt_merged=1
is_merged="${green}βœ“ merged${bright_black} | "
fi
echo -e "Worktree ($wt_idx/$worktree_count): ${green}$wt_path${reset}"
if [ -n "$wt_branch" ]; then
echo -e "${is_merged}${bright_black}branch: $wt_branch | $wt_state${reset}"
options=("skip" "worktree" "worktree + branch")
else
echo -e "${bright_black}(detached HEAD) | $wt_state${reset}"
options=("skip" "worktree")
fi
echo "Delete?"
select_option "${options[@]}"
selected_option=$?
selected_text=${options[$selected_option]}
handle_worktree_deletion "$selected_text" "$wt_path" "$wt_branch" "$wt_merged"
echo
done
fi
read -p "🌿 Do you want to prune stale remote-tracking branches again? (y/n) " prune_choice
if [[ $prune_choice =~ ^[Yy]$ ]]; then
echo -e "\n${blue}Pruning stale remote-tracking branches...${reset}"
if git remote prune origin; then
echo -e "βœ… Remote pruning complete!"
else
echo -e "${red}Failed to prune remote branches${reset}"
fi
fi
read -p "🧹 Do you want to clean up unreachable local objects? (y/n) " cleanup_choice
if [[ $cleanup_choice =~ ^[Yy]$ ]]; then
echo -e "\n${blue}Running local cleanup...${reset}"
# First run gc which is safer and does most of the work
echo "Running git gc (garbage collection)..."
if git gc --auto; then
echo -e "βœ… Garbage collection complete!"
else
echo -e "${red}Warning: Garbage collection had issues${reset}"
fi
# Only run fsck + prune if user really wants deep cleanup
if [ -n "$force_deleted_any" ]; then
echo -e "${red}⚠ You force-deleted unmerged branches this run β€” deep cleanup would erase their commits permanently (no recovery via reflog).${reset}"
fi
read -p "Run deep cleanup (fsck + prune)? This is aggressive. (y/n) " deep_choice
if [[ $deep_choice =~ ^[Yy]$ ]]; then
echo "Checking object database integrity..."
git fsck --full 2>&1 | head -n 20
echo "Pruning unreachable objects..."
if git prune --expire=now; then
echo -e "βœ… Deep cleanup complete!"
else
echo -e "${red}Warning: Prune had issues${reset}"
fi
fi
else
echo -e "${bright_black}Skipping local cleanup.${reset}"
fi
echo -e "\nβœ… Script completed successfully."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment