Last active
March 26, 2026 17:00
-
-
Save danielrose7/700415aedd7e78e200ec1bff21354073 to your computer and use it in GitHub Desktop.
trim — zsh function to delete local git branches (+ worktrees) that have been merged. Catches both true merges and squash merges via GitHub PR status. Auto-detects default branch from origin/HEAD.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Delete local branches merged to the default branch + their worktrees | |
| # Catches both true merges (git) and squash merges (GitHub PR status) | |
| # Reads default branch from origin/HEAD — works with main, staging, etc. | |
| # Usage: trim [--force] (--force skips confirmation) | |
| trim() { | |
| local repo_root | |
| repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "Not in a git repo"; return 1; } | |
| local base_branch | |
| base_branch="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null)" | |
| base_branch="${base_branch##refs/remotes/origin/}" | |
| if [[ -z "$base_branch" ]]; then | |
| echo "Cannot determine default branch. Run: git remote set-head origin --auto" | |
| return 1 | |
| fi | |
| git fetch origin "$base_branch" --quiet | |
| # 1) Branches merged via git (fast-forward / true merge) | |
| local -A to_delete=() | |
| local branch | |
| while IFS= read -r branch; do | |
| branch="${branch//[[:space:]]/}" | |
| branch="${branch#+}" | |
| [[ -z "$branch" || "$branch" == "$base_branch" || "$branch" == "main" || "$branch" == \** ]] && continue | |
| to_delete[$branch]=git | |
| done < <(git branch --merged "origin/$base_branch") | |
| # 2) Branches whose GitHub PR was squash-merged | |
| local -a local_branches=() | |
| while IFS= read -r branch; do | |
| branch="${branch//[[:space:]]/}" | |
| branch="${branch#+}" | |
| [[ -z "$branch" || "$branch" == "$base_branch" || "$branch" == "main" || "$branch" == \** ]] && continue | |
| local_branches+=("$branch") | |
| done < <(git branch) | |
| if [[ ${#local_branches[@]} -gt 0 ]]; then | |
| local -A merged_prs=() | |
| while IFS= read -r branch; do | |
| [[ -n "$branch" ]] && merged_prs[$branch]=1 | |
| done < <(gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null) | |
| for branch in "${local_branches[@]}"; do | |
| if [[ -n "${merged_prs[$branch]+x}" && -z "${to_delete[$branch]+x}" ]]; then | |
| to_delete[$branch]=squash | |
| fi | |
| done | |
| fi | |
| if [[ ${#to_delete} -eq 0 ]]; then | |
| echo "No merged branches to clean up." | |
| git worktree prune | |
| return 0 | |
| fi | |
| # Build worktree map | |
| local wt_info wt_path answer | |
| wt_info="$(git worktree list --porcelain)" | |
| echo "Branches to delete (${#to_delete} total):" | |
| for branch in "${(@k)to_delete}"; do | |
| wt_path=$(echo "$wt_info" | awk -v b="refs/heads/${branch}" '/^worktree /{wt=$2} /^branch /{if($2==b)print wt}') | |
| local label="[${to_delete[$branch]}]" | |
| if [[ -n "$wt_path" ]]; then | |
| echo " $label $branch → $wt_path" | |
| else | |
| echo " $label $branch" | |
| fi | |
| done | |
| if [[ "$1" != "--force" ]]; then | |
| echo "" | |
| read "answer?Proceed? [y/N] " | |
| [[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; return 0; } | |
| fi | |
| local count=0 | |
| for branch in "${(@k)to_delete}"; do | |
| wt_path=$(echo "$wt_info" | awk -v b="refs/heads/${branch}" '/^worktree /{wt=$2} /^branch /{if($2==b)print wt}') | |
| if [[ -n "$wt_path" && -d "$wt_path" ]]; then | |
| echo "Removing worktree: $wt_path" | |
| rm -rf "$wt_path" | |
| fi | |
| git branch -D "$branch" 2>/dev/null | |
| (( count++ )) | |
| done | |
| git worktree prune | |
| echo "Done. Cleaned $count branch(es)." | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment