Git 2.56 ships branch --delete-merged: why my gbm and gbdm shell helpers stay
Git 2.56 can now sweep merged local branches natively, but its ancestry-only check misses the squash merges and rebased patches my workflow produces, so my shell helpers stay.
Git 2.56 added git branch --delete-merged, a native sweep for local branches whose work already landed upstream. I tested it against the gbm and gbdm shell functions in my dotfiles, and in a GitHub/GitLab pull-request workflow it deleted nothing. Here is what the feature does, what my helpers do, and why both keep a place.
Note
Tested with Git 2.56.0 on macOS on 2026-09-30.
The new feature
The syntax, from the git-branch man page:
git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]
A branch is deleted only when every condition holds:
- it has a configured upstream matching a
<pattern>: a ref (origin/main), a remote name (that remote’s HEAD branch), or a glob (origin/*); - its tip is reachable from that upstream, which means ancestry only: true merges and fast-forwards;
- it is not checked out in any worktree;
- its configured upstream ref still exists;
- pushing it, per the remote’s push refspecs and
push.default, would not update its own upstream. This is what keeps a localmainalive, since it trivially looks merged right after every pull; - it is not the local upstream of a branch that survives the sweep, so stacked work is never deleted out from under a topic branch;
branch.<name>.deleteMergedis not set tofalse, a per-branch opt-out for topics worth keeping after an early round merged.
Anything unmerged is silently skipped; the command never force-deletes. --dry-run prints what would go, and it is rejected unless paired with --delete-merged.
The series is six commits by Harald Nordgren (compare range) leading with the feature commit and closing with the config opt-out and --dry-run.
What my helpers do
I keep two functions in ~/dotfiles/.config/shell/common.d/10-git.sh, shared by Bash and Zsh:
gbm(git-branches-merged) fetches the default base,origin/HEADfalling back tomainormaster, then lists local branches whose work is already incorporated. It refuses to classify anything if the fetch fails, so it never works from stale remote state.gbdm(git-branch-delete-merged) runsgbm, triesgit branch -dfor each result, and asks before force-deleting branches that only look merged through content.
“Incorporated” is the interesting part. _git_branch_done accepts a branch when any of these holds:
- the tip is an ancestor of the base, the same ancestry check Git now ships;
git cherryfinds no unique patches, which catches rebases and cherry-picks where the commits landed with different SHAs;- a synthetic commit test detects squash merges: build a dangling commit with the branch’s tree and merge base as parent using
git commit-tree, then askgit cherrywhether that aggregate patch exists on the base. I adapted this from git-trim and git-delete-squashed; - the forge records the merge:
gh pr list --state mergedon GitHub,glab mr list --mergedon GitLab, matching the branch tip’s OID against the recorded head of a merged PR or MR.
The dotfiles header notes the tradeoff: git-trim via Homebrew is faster and covers more remote workflows, but these functions add per-branch force-delete confirmation and equal gh/glab fallbacks.
The implementation keeps gbm and gbdm as separate functions: gbm handles classification and listing, while gbdm handles deletion and interactive confirmation.
# ~/dotfiles/.config/shell/common.d/10-git.sh
# shellcheck shell=bash
# Shared Git helpers for Bash and Zsh.
# Alternative: Homebrew provides git-trim (`brew install git-trim`), a mature,
# faster cleanup tool with broader remote workflows. We keep this implementation
# for per-branch force-delete confirmation and equal gh/glab fallbacks, accepting
# the extra shell maintenance and slower sequential forge checks.
# Resolve the repo's default branch: origin/HEAD, then main, then master.
_git_default_branch() {
local ref
ref="$(git symbolic-ref refs/remotes/origin/HEAD --short 2>/dev/null)" \
&& ref="${ref#origin/}" && [[ -n "$ref" ]] && { echo "$ref"; return; }
git rev-parse --verify --quiet main >/dev/null 2>&1 && { echo main; return; }
git rev-parse --verify --quiet master >/dev/null 2>&1 && { echo master; return; }
echo main
}
# Refresh and resolve the remote default branch when origin is available.
_git_default_base() {
local branch
if git remote get-url origin >/dev/null 2>&1; then
git fetch -q origin || {
printf 'Unable to refresh origin; refusing to classify branches.\n' >&2
return 1
}
branch="$(_git_default_branch)" || return 1
git rev-parse --verify --quiet "origin/${branch}^{commit}" >/dev/null || return 1
printf 'origin/%s\n' "$branch"
return 0
fi
_git_default_branch
}
# Refresh an explicitly selected remote base before using it.
_git_resolve_base() {
local base="$1" remote rest
case "$base" in
refs/remotes/*/*)
rest="${base#refs/remotes/}"
remote="${rest%%/*}"
;;
*/*)
remote="${base%%/*}"
git remote get-url "$remote" >/dev/null 2>&1 || remote=""
;;
*) remote="" ;;
esac
if [[ -n "$remote" ]]; then
git fetch -q "$remote" || {
printf 'Unable to refresh %s; refusing to classify branches.\n' "$remote" >&2
return 1
}
fi
git rev-parse --verify --quiet "${base}^{commit}" >/dev/null || return 1
printf '%s\n' "$base"
}
_git_base_branch_name() {
local base_ref rest
base_ref="$(git rev-parse --symbolic-full-name "$1")" || return 1
case "$base_ref" in
refs/heads/*) printf '%s\n' "${base_ref#refs/heads/}" ;;
refs/remotes/*/*)
rest="${base_ref#refs/remotes/}"
printf '%s\n' "${rest#*/}"
;;
*) return 1 ;;
esac
}
# Validate normalized <head-oid><tab><integration-oid> records from a forge.
_git_forge_records_branch_done() {
local base="$1" branch="$2" records="$3" branch_oid forge_head integration_oid
branch_oid="$(git rev-parse "${branch}^{commit}")" || return 1
while IFS=$'\t' read -r forge_head integration_oid; do
[[ -n "$forge_head" && -n "$integration_oid" ]] || continue
git merge-base --is-ancestor "$integration_oid" "$base" 2>/dev/null || continue
if [[ "$branch_oid" == "$forge_head" ]] || \
git merge-base --is-ancestor "$branch_oid" "$forge_head" 2>/dev/null; then
return 0
fi
done <<<"$records"
return 1
}
# True when GitHub records this branch tip as merged into the selected base.
_git_branch_done_github() {
local base="$1" branch="$2" base_name records
command -v gh >/dev/null 2>&1 || return 1
base_name="$(_git_base_branch_name "$base")" || return 1
records="$(gh pr list --state merged --head "$branch" --base "$base_name" --limit 100 \
--json headRefOid,mergeCommit \
--jq '.[] | [.headRefOid, (.mergeCommit.oid // "")] | @tsv' 2>/dev/null)" || return 1
_git_forge_records_branch_done "$base" "$branch" "$records"
}
# True when GitLab records this branch tip as merged into the selected base.
_git_branch_done_gitlab() {
local base="$1" branch="$2" base_name records
command -v glab >/dev/null 2>&1 || return 1
base_name="$(_git_base_branch_name "$base")" || return 1
records="$(glab mr list --merged --source-branch "$branch" --target-branch "$base_name" \
--per-page 100 --output json \
--jq '.[] | [[.sha, .merge_commit_sha], [.sha, .squash_commit_sha]][] | select(.[1] != null and .[1] != "") | @tsv' \
2>/dev/null)" || return 1
_git_forge_records_branch_done "$base" "$branch" "$records"
}
# Check the branch's aggregate patch using the technique from git-trim and
# git-delete-squashed: create a dangling commit with the branch tree and its
# merge base as parent, then ask Git whether that patch exists on the base.
_git_squash_patch_merged() {
local base="$1" branch="$2" merge_base tree synthetic cherry_output
merge_base="$(git merge-base "$base" "$branch")" || return 1
tree="$(git rev-parse "${branch}^{tree}")" || return 1
synthetic="$(
GIT_AUTHOR_NAME=gbm GIT_AUTHOR_EMAIL=gbm@localhost \
GIT_COMMITTER_NAME=gbm GIT_COMMITTER_EMAIL=gbm@localhost \
git commit-tree "$tree" -p "$merge_base" -m 'gbm squash-merge test'
)" || return 1
cherry_output="$(git cherry "$base" "$synthetic" 2>/dev/null)" || return 1
[[ "$cherry_output" == "- $synthetic" ]]
}
# True when merging <branch> into <base> would add no commits or content.
_git_branch_done() {
local base="$1" branch="$2" unique_merges cherry_output
git rev-parse --verify --quiet "${base}^{commit}" >/dev/null || return 1
git rev-parse --verify --quiet "${branch}^{commit}" >/dev/null || return 1
git merge-base --is-ancestor "$branch" "$base" && return 0
unique_merges="$(git rev-list --merges --max-count=1 "${base}..${branch}")" || return 1
if [[ -z "$unique_merges" ]]; then
cherry_output="$(git cherry "$base" "$branch" 2>/dev/null)" || return 1
! grep -q '^+' <<<"$cherry_output" && return 0
fi
_git_squash_patch_merged "$base" "$branch" && return 0
_git_branch_done_github "$base" "$branch" && return 0
_git_branch_done_gitlab "$base" "$branch"
}
# List local branches already incorporated into <base> by history or content.
# Excludes the default branch, main, master, and the checked-out branch.
gbm() {
local base base_name current b
if [[ $# -gt 0 ]]; then
base="$(_git_resolve_base "$1")" || return 1
else
base="$(_git_default_base)" || return 1
fi
base_name="$(_git_base_branch_name "$base" 2>/dev/null || true)"
current="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
while IFS= read -r b; do
[[ "$b" == "$base_name" || "$b" == main || "$b" == master || "$b" == "$current" ]] && continue
_git_branch_done "$base" "$b" && printf '%s\n' "$b"
done < <(git branch --format='%(refname:short)')
# The loop's status leaks the classification of the alphabetically last
# branch; a completed listing is success regardless of that outcome.
return 0
}
_git_confirm_force_delete() {
local branch="$1" answer
if [[ ! -t 0 && ! -t 1 && ! -t 2 ]]; then
printf 'Keeping %s: force deletion requires an interactive terminal.\n' "$branch" >&2
return 1
fi
printf 'Force-delete %s? [y/N] ' "$branch" >/dev/tty || return 1
IFS= read -r answer </dev/tty || return 1
[[ "$answer" == y || "$answer" == Y ]]
}
# Delete incorporated local branches. Try Git's safe deletion first and ask
# before force-deleting branches integrated through a rebase or squash.
gbdm() {
local branches b
branches="$(gbm "$@")" || return 1
[[ -z "$branches" ]] && return 0
while IFS= read -r b; do
git branch -d "$b" && continue
if _git_confirm_force_delete "$b"; then
git branch -D "$b"
else
printf 'Keeping %s.\n' "$b"
fi
done <<<"$branches"
}
The comparison
I set up a scratch repository with one branch per case and ran both tools:
| Branch state | --delete-merged | gbm |
|---|---|---|
True merge, tracks origin/feature | skipped | found |
| Squash-merged | skipped | found (synthetic-commit test) |
| Rebased or cherry-picked | skipped | found (git cherry) |
| Merged via PR or MR | skipped | found (gh/glab) |
| No upstream configured | skipped | found |
Tracks origin/main, pushed as topic:main | deleted | found |
Why the skips? Two mechanisms get in the way for the first row: the push-refspec guard treats git push origin feature as updating that branch’s own upstream, and ancestry-only detection ignores squash merges and rebases entirely. Even the glob form --delete-merged 'origin/*' offered nothing in a repository where each branch tracks its own origin/<name>.
That is by design. The feature’s test suite (t/t3200-branch.sh) targets a different workflow: local branches that track the shared integration branch and push work directly to it, git checkout -b topic --track origin/next && git push origin topic:next. That is how the Git project itself uses next, and kernel-style trees work the same way. In that model, “tip reachable from tracked upstream” is exactly the right deletion test.
In a fork-and-PR workflow, where git push -u origin feature makes each branch track its own remote name and the forge squash-merges or rebases the work, neither condition the feature checks for ever fires.
Why mine stay
The new command solves a narrower problem than the one my helpers target: which branches are safe to delete when history can land through merges, rebases, squash merges, or the forge’s merge button. Until Git grows content-level merge detection, gbm and gbdm keep their job.
I did adopt one piece: in repositories where I push topics straight to an integration branch, git branch --dry-run --delete-merged origin is a clean preview, and branch.<name>.deleteMerged=false is a tidy opt-out for keep-forever topics. The protections are good: any-worktree checkout checks and stacked-branch safety that my helpers lack.
References
- git-branch documentation: official reference for
--delete-merged,--dry-run, andbranch.<name>.deleteMerged - Feature series on git.git: the six commits by Harald Nordgren
- git-trim: origin of the upstream-branch trimming ideas
- git-delete-squashed: source of the synthetic-commit squash test
This post was written with AI assistance.