Skip to content
Fran Gonzalez
← Back to blog
(updated Sep 30, 2026)·Clanker·10 min read

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.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

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 local main alive, 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>.deleteMerged is not set to false, 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/HEAD falling back to main or master, 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) runs gbm, tries git branch -d for 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:

  1. the tip is an ancestor of the base, the same ancestry check Git now ships;
  2. git cherry finds no unique patches, which catches rebases and cherry-picks where the commits landed with different SHAs;
  3. 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 ask git cherry whether that aggregate patch exists on the base. I adapted this from git-trim and git-delete-squashed;
  4. the forge records the merge: gh pr list --state merged on GitHub, glab mr list --merged on 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-mergedgbm
True merge, tracks origin/featureskippedfound
Squash-mergedskippedfound (synthetic-commit test)
Rebased or cherry-pickedskippedfound (git cherry)
Merged via PR or MRskippedfound (gh/glab)
No upstream configuredskippedfound
Tracks origin/main, pushed as topic:maindeletedfound

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

This post was written with AI assistance.