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.
🤍🎩
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." |