Skip to content

Instantly share code, notes, and snippets.

@dfl
Created April 24, 2026 18:25
Show Gist options
  • Select an option

  • Save dfl/ed23c5647f0092df2fa069c3bc86aa1b to your computer and use it in GitHub Desktop.

Select an option

Save dfl/ed23c5647f0092df2fa069c3bc86aa1b to your computer and use it in GitHub Desktop.
prune stale git branches and worktrees
#!/usr/bin/env bash
# Prune local branches whose PRs are merged (and optionally closed-unmerged).
# Also removes the matching worktree if one exists.
#
# Usage:
# prune-stale-branches # prompt per merged branch (default)
# prune-stale-branches -f # also include closed-unmerged PRs
# prune-stale-branches --apply # no prompts, delete all candidates
# prune-stale-branches --dry-run # print only, no deletion
set -euo pipefail
APPLY=0
INCLUDE_CLOSED=0
PROMPT=1
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--apply|-y) APPLY=1; PROMPT=0 ;;
-f|--force-closed) INCLUDE_CLOSED=1 ;;
-p|--prompt) PROMPT=1; APPLY=0 ;;
-n|--dry-run) DRY_RUN=1; PROMPT=0; APPLY=0 ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
*) echo "unknown arg: $arg" >&2; exit 2 ;;
esac
done
command -v gh >/dev/null || { echo "gh CLI required" >&2; exit 1; }
git rev-parse --git-dir >/dev/null || exit 1
PROTECTED_RE='^(main|master|HEAD)$'
CURRENT="$(git symbolic-ref --short -q HEAD || true)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
JQ_FILTER='.[] | select(.state=="MERGED"'
[[ $INCLUDE_CLOSED -eq 1 ]] && JQ_FILTER="$JQ_FILTER"' or .state=="CLOSED"'
JQ_FILTER="$JQ_FILTER"') | [.headRefName, .state] | @tsv'
gh pr list --state all --limit 500 --json headRefName,state --jq "$JQ_FILTER" \
| sort -u > "$TMP/prs"
# worktree map via porcelain: emit "branch<TAB>path"
awk '
/^worktree / { p=$2 }
/^branch refs\/heads\// { sub("refs/heads/","",$2); print $2 "\t" p }
' <(git worktree list --porcelain) > "$TMP/wts"
lookup_state() { awk -F'\t' -v b="$1" '$1==b{print $2; exit}' "$TMP/prs"; }
lookup_wt() { awk -F'\t' -v b="$1" '$1==b{print $2; exit}' "$TMP/wts"; }
act() {
if [[ $DRY_RUN -eq 1 ]]; then echo " DRY: $*"; else "$@"; fi
}
pruned=0
skipped=0
while read -r branch; do
[[ -z "$branch" ]] && continue
[[ "$branch" =~ $PROTECTED_RE ]] && continue
if [[ "$branch" == "$CURRENT" ]]; then echo "skip $branch (current)"; continue; fi
state="$(lookup_state "$branch")"
if [[ -z "$state" ]]; then skipped=$((skipped+1)); continue; fi
echo "prune $branch ($state)"
wt="$(lookup_wt "$branch")"
if [[ $PROMPT -eq 1 ]]; then
[[ -n "$wt" ]] && echo " worktree: $wt"
printf " delete? [y/N/q] "
read -r ans </dev/tty
case "$ans" in
y|Y) ;;
q|Q) echo "aborted."; break ;;
*) echo " skipped."; skipped=$((skipped+1)); continue ;;
esac
fi
if [[ -n "$wt" ]]; then
act git worktree remove "$wt"
fi
act git branch -D "$branch"
pruned=$((pruned+1))
done < <(git for-each-ref --format='%(refname:short)' refs/heads/)
echo
echo "candidates: $pruned, unmatched local branches: $skipped"
[[ $DRY_RUN -eq 1 ]] && echo "(dry-run — rerun without --dry-run to delete)"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment