Skip to content

Instantly share code, notes, and snippets.

@BigBlueHat
Created July 25, 2026 16:46
Show Gist options
  • Select an option

  • Save BigBlueHat/ee5c0e3e5a2d8c1ea9194ce764700376 to your computer and use it in GitHub Desktop.

Select an option

Save BigBlueHat/ee5c0e3e5a2d8c1ea9194ce764700376 to your computer and use it in GitHub Desktop.
Remove git branches you no longer need.

This is a heavily AI revised/generated shell script based on this StackOverflow answer. See the comments in the script for more details.

Obviously, use at your own risk. NO WARRANTY EXPRESSED OR IMPLIED.

🤍🎩

#!/bin/bash
# originally from https://github.com/kortina/bakpak/blob/master/bin/git-branches-vs-origin-master
# found via https://stackoverflow.com/a/7774433
# heavily revised by https://duck.ai/
# original by http://github.com/kortina
# which was modified from http://github.com/jehiah
#
# License:
# This script is based on a Stack Overflow answer (CC BY-SA 4.0).
# Copyright © the original author(s) of that answer.
#
# You must attribute the original source and provide a link to the license:
# https://creativecommons.org/licenses/by-sa/4.0/
#
# If you redistribute or modify this script, you must distribute it under the
# same license terms (CC BY-SA 4.0).
#
# Original Stack Overflow answer:
# https://stackoverflow.com/a/7774433
# ----- AI generated notes below, followed by AI generated code from duck.ai
# Branch cleanup helper: delete default-branch-contained branches from local + remote.
#
# What it covers (by default)
# - Detects the default branch name for the given remote (via origin/HEAD if available; else
# remote show; else falls back to master/main if those remote-tracking refs exist).
# - Computes a frozen default tip SHA once at the start (so the analysis is done against a
# stable revision while the script runs).
# - Skips deleting the default branch itself and also skips any branch named "HEAD".
# - Deletes:
# 1) Local branches whose history is strictly contained in the frozen default tip:
# git merge-base --is-ancestor refs/heads/<b> <default_tip_sha>
# 2) Remote-only branches whose remote-tracking history is strictly contained in the frozen
# default tip:
# git merge-base --is-ancestor refs/remotes/origin/<b> <default_tip_sha>
# - Prevents accidental deletion of branches that contain commits not reachable from the default
# tip at the time analysis was taken (strict containment / ancestor test).
#
# What it does NOT (and cannot perfectly) cover
# - Races: the remote can change between the analysis phase and the deletion phase.
# Mitigation included below: it re-checks the remote HEAD SHA immediately before deletion for
# remote-tracking refs it intends to delete.
# - Non-standard workflows:
# - If your default branch is not reachable from the remote (or refs are unusual), default
# detection may choose a fallback.
# - Subtle rename/force-push scenarios:
# - If the branch is force-updated after analysis but before deletion, the re-check will likely
# prevent deletion (or at least it will ensure the SHA still matches what was analyzed).
# - Cases where the concept of "contained" depends on additional refs (e.g., tags or other
# integration branches) rather than just the default branch tip.
#
# Safety features included
# - Dry-run mode (default is enabled via DRY_RUN=1) prints what would be deleted.
# - Remote deletion re-check: right before pushing --delete, it confirms the remote-tracking
# ref still exists AND still points to the same SHA that was analyzed.
#
# Usage
# - Dry-run (default): ./script.sh
# - Live delete: DRY_RUN=0 ./script.sh
#
# Environment variables
# - REMOTE (default: origin)
# - DRY_RUN (default: 1)
#
set -euo pipefail
REMOTE="${REMOTE:-origin}"
DRY_RUN="${DRY_RUN:-1}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
detect_default_branch() {
if head_sym="$(git symbolic-ref --quiet --short "${REMOTE}/HEAD" 2>/dev/null)"; then
bn="${head_sym#${REMOTE}/}"
if [[ -n "$bn" ]]; then echo "$bn"; return 0; fi
fi
if dflt="$(git remote show "$REMOTE" 2>/dev/null | awk -F': ' '/HEAD branch/ {print $2}' | tr -d '\r')"; then
if [[ -n "$dflt" ]]; then echo "$dflt"; return 0; fi
fi
if git show-ref --verify --quiet "refs/remotes/${REMOTE}/master"; then echo "master"; return 0; fi
if git show-ref --verify --quiet "refs/remotes/${REMOTE}/main"; then echo "main"; return 0; fi
echo ""
}
DEFAULT_BRANCH="$(detect_default_branch)"
if [[ -z "$DEFAULT_BRANCH" ]]; then
echo "Could not detect default branch on ${REMOTE}."
exit 1
fi
DEFAULT_REMOTE_REF="refs/remotes/${REMOTE}/${DEFAULT_BRANCH}"
DEFAULT_TIP_SHA="$(git rev-parse "$DEFAULT_REMOTE_REF")"
is_skipped_name() {
local name="$1"
[[ "$name" == "$DEFAULT_BRANCH" ]] && return 0
[[ "$name" == "HEAD" ]] && return 0
return 1
}
# Strict containment: $1 ref is an ancestor of the frozen default tip SHA.
is_ancestor_of_default() {
local branch_ref="$1"
git merge-base --is-ancestor "$branch_ref" "$DEFAULT_TIP_SHA" 2>/dev/null
}
# Local set
declare -A local_set=()
while read -r b; do
[[ -n "$b" ]] && local_set["$b"]=1
done < <(git for-each-ref --format='%(refname:short)' refs/heads/)
# candidates: store branch name -> type flags
# type bits: L=local, R=remote
declare -A cand_type=()
# Analyze local branches
while read -r local_branch; do
is_skipped_name "$local_branch" && continue
local_ref="refs/heads/${local_branch}"
if is_ancestor_of_default "$local_ref"; then
cand_type["$local_branch"]="${cand_type[$local_branch]:-}L"
fi
done < <(git for-each-ref --format='%(refname:short)' refs/heads/)
# Analyze remote-only branches and remote-tracking containment
# Enumerate refs/remotes/${REMOTE}/* but strip "origin/".
# Note: remote-only meaning local branch may not exist; we include it for remote deletion.
while read -r remote_branch; do
[[ -z "$remote_branch" ]] && continue
is_skipped_name "$remote_branch" && continue
[[ -n "${local_set[$remote_branch]+x}" ]] && continue # local already analyzed; don't treat as remote-only
remote_tracking_ref="refs/remotes/${REMOTE}/${remote_branch}"
if git show-ref --verify --quiet "$remote_tracking_ref"; then
if is_ancestor_of_default "$remote_tracking_ref"; then
cand_type["$remote_branch"]="${cand_type[$remote_branch]:-}R"
fi
fi
done < <(git for-each-ref --format='%(refname:short)' "refs/remotes/${REMOTE}/" | sed "s#^${REMOTE}/##")
if (( ${#cand_type[@]} == 0 )); then
echo "No branches found to delete that are strictly contained in ${DEFAULT_REMOTE_REF} (${DEFAULT_TIP_SHA})."
exit 0
fi
# Snapshot remote-tracking SHAs for re-check before deletion
# remote_sha_map[branch]=sha (only for branches marked for R deletion)
declare -A remote_sha_map=()
for b in "${!cand_type[@]}"; do
if [[ "${cand_type[$b]}" == *R* ]]; then
rt="refs/remotes/${REMOTE}/${b}"
if git show-ref --verify --quiet "$rt"; then
remote_sha_map["$b"]="$(git rev-parse "$rt")"
else
# If the tracking ref is gone, we won't be able to verify deletion; treat as not deletable.
remote_sha_map["$b"]=""
fi
fi
done
# Display plan
mapfile -t branches < <(printf '%s\n' "${!cand_type[@]}" | sort -u)
echo "Default branch skipped: ${DEFAULT_BRANCH}"
echo "Frozen default tip: ${DEFAULT_TIP_SHA}"
echo
echo "Dry-run: $DRY_RUN (set DRY_RUN=0 to actually delete)"
echo "Branches to delete:"
printf '%s\n' "${branches[@]}" | awk '{print " - " $0}'
echo
read -r -p "Proceed with ${DRY_RUN:+dry-run }plan? [y/N] " reply
reply="${reply:-N}"
if [[ "$reply" != "y" && "$reply" != "Y" ]]; then
echo "Aborted."
exit 0
fi
delete_local_if_marked() {
local b="$1"
if [[ "${cand_type[$b]}" == *L* ]]; then
if git show-ref --verify --quiet "refs/heads/${b}"; then
if [[ "$DRY_RUN" == "1" ]]; then
echo "[DRY_RUN] Would delete local branch: ${b}"
else
git branch -D "$b" 2>/dev/null || true
echo "[LIVE] Deleted local branch: ${b}"
fi
fi
fi
}
delete_remote_if_marked() {
local b="$1"
if [[ "${cand_type[$b]}" != *R* ]]; then
return 0
fi
local rt="refs/remotes/${REMOTE}/${b}"
local expected_sha="${remote_sha_map[$b]:-}"
# Re-check tracking ref exists AND still matches the analyzed SHA.
if ! git show-ref --verify --quiet "$rt"; then
echo "[SKIP] Remote tracking ref missing: ${rt}"
return 0
fi
local current_sha
current_sha="$(git rev-parse "$rt")"
if [[ -z "$expected_sha" ]]; then
echo "[SKIP] No expected SHA captured for ${b} (won't delete remote)."
return 0
fi
if [[ "$current_sha" != "$expected_sha" ]]; then
echo "[SKIP] Remote tracking SHA changed for ${b}: expected ${expected_sha}, got ${current_sha}"
return 0
fi
if [[ "$DRY_RUN" == "1" ]]; then
echo "[DRY_RUN] Would delete remote branch on ${REMOTE}: ${b}"
else
git push "${REMOTE}" --delete "$b" 2>/dev/null || true
echo "[LIVE] Deleted remote branch on ${REMOTE}: ${b}"
fi
}
for b in "${branches[@]}"; do
delete_local_if_marked "$b"
delete_remote_if_marked "$b"
done
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment