#!/usr/bin/env bash # branch-drift-audit.sh — surface stale/stranded branches BEFORE they rot. # # The failure this prevents: a feature branch left unlanded while main moves 100+ # commits drifts into an un-mergeable state, stranding real work. This makes drift # VISIBLE and, crucially, distinguishes "far behind but fully absorbed" (safe to # delete) from "far behind with genuinely-unlanded commits" (cherry-pick forward # NOW, before it rots further). Run it in CI or from a cron; act on anything with # STRANDED > 0 or a large BEHIND. # # Usage: branch-drift-audit.sh [base-ref] (default: origin/main) set -euo pipefail cd "$(git rev-parse --show-toplevel)" git fetch -q origin 2>/dev/null || true BASE="${1:-origin/main}" WARN_BEHIND="${WARN_BEHIND:-50}" # flag branches this far behind base printf "%-28s %6s %6s %9s %9s %s\n" "BRANCH" "AHEAD" "BEHIND" "UNPUSHED" "STRANDED" "VERDICT" printf '%.0s─' {1..96}; echo # Dedup local + origin refs into one branch list. branches=$(git for-each-ref --format='%(refname:short)' refs/heads/ refs/remotes/origin/ \ | sed 's#^origin/##' | grep -vx 'HEAD' | grep -vx 'main' | sort -u) for b in $branches; do ref="refs/heads/$b"; git show-ref -q --verify "$ref" || ref="refs/remotes/origin/$b" ab=$(git rev-list --left-right --count "$BASE...$ref" 2>/dev/null) || continue behind=$(awk '{print $1}' <<<"$ab"); ahead=$(awk '{print $2}' <<<"$ab") # Genuinely-unlanded = commits whose PATCH is not on base (git cherry '+'). stranded=$(git cherry "$BASE" "$ref" 2>/dev/null | grep -c '^+' || true) # Unpushed = local commits not on the same-named remote branch. if git show-ref -q --verify "refs/heads/$b" && git show-ref -q --verify "refs/remotes/origin/$b"; then unpushed=$(git rev-list --count "origin/$b..refs/heads/$b" 2>/dev/null || echo 0) elif git show-ref -q --verify "refs/heads/$b"; then unpushed="NO-REMOTE"; else unpushed=0; fi if [ "$stranded" -gt 0 ]; then verdict="⚠ LAND $stranded unlanded commit(s) — cherry-pick forward" elif [ "$ahead" -eq 0 ]; then verdict="✓ absorbed — safe to delete" else verdict="✓ ahead-commits all on $BASE (patch-equiv) — deletable"; fi [ "$unpushed" != "0" ] && [ "$unpushed" != 0 ] && verdict="$verdict · PUSH first" [ "$behind" -gt "$WARN_BEHIND" ] && verdict="$verdict · ${behind} behind" printf "%-28s %6s %6s %9s %9s %s\n" "$b" "$ahead" "$behind" "$unpushed" "$stranded" "$verdict" done