Skip to content

branch: delete-merged#2285

Open
HaraldNordgren wants to merge 7 commits into
git:masterfrom
HaraldNordgren:fetch-prune-local-branches
Open

branch: delete-merged#2285
HaraldNordgren wants to merge 7 commits into
git:masterfrom
HaraldNordgren:fetch-prune-local-branches

Conversation

@HaraldNordgren

@HaraldNordgren HaraldNordgren commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Delete branches that have already been merged on upstream.

Changes in v20:

  • Protect branches transitively required by a surviving local upstream stack. Traverse upstream chains once and defer delete-set mutation until traversal completes.
  • Make stacked-branch handling independent of ref iteration order and update the documentation accordingly.
  • Clarify variable names with regards to branch names (short) to reduce confusion.

Changes in v19:

  • Fix bug where dry-run would still remove config, added test coverage.
  • Redesigned --delete-merged as a repeatable upstream selector with optional positional patterns limiting deletion scope.
  • Protect same-name upstream branches independently of push-default configuration.
  • Simplified flags handling where local caching became complicated when mutating values.
  • Clarified assertions in tests.

Changes in v18:

  • Instead of keeping the whole chain of upstream branches, keep only the ones an unmerged branch still needs. When a kept (merged) branch in turn tracks a branch that is being deleted, clear its now-stale upstream config.
  • Rework spare_stacked_bases() to record the kept bases and, in a second pass, clear the upstream of any whose own base is going away. Build the to-delete list with strset_for_each_entry() instead of re-walking the candidate array.

Changes in v17:

  • Keep a merged branch when another surviving branch still tracks it as its upstream, so --delete-merged no longer deletes a branch out from under one stacked on top of it.
  • Move the --dry-run and branch.<name>.deleteMerged opt-out fully into their own commits.

Changes in v16:

  • Convert delete_merged_branches() to take an unsigned int flags argument instead of separate quiet/dry_run booleans, matching delete_branches()
  • Reuse the strbuf across the skip-config loop (strbuf_reset per iteration, single strbuf_release after) instead of allocating and freeing it each time
  • Rewrite the --delete-merged tests as integration tests: branches that land commits upstream, with deletion and the checked-out, upstream-gone, and push-equals-upstream safety cases exercised together in one run and output asserted via test_cmp
  • Collapse the many per-aspect test repos into a single reused repo set up by a setup_repo_for_delete_merged helper, and rename helpers off the old pm_/prune naming
  • Nest single-repo setup sequences in ( cd ... ) subshells instead of prefixing every command with -C

Changes in v15:

  • Renamed --prune-merged to --delete-merged throughout. Not necessarily
    final, but something to advance the discussion.
  • --delete-merged now silently skips not-yet-merged branches instead of
    warning.
  • Initialized the delete_branches() flag locals where declared. Only force
    stays deferred.
  • delete_branches()/check_branch_commit() doc and code cleanups: redundant
    branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
    unreachable non-branch ref, and reworked --delete-merged doc wording.
  • Broadened the --forked tests (local commits for realism, remote add -f,
    --forked coverage), renamed the misleading trunk
    fixture, and replaced the misnamed detached branch with git checkout
    --detach.

Changes in v14:

  • Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
    remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
    check_branch_commit() reads, so it wrongly ran the merge check.
  • Made flags the single source of truth in delete_branches() so the bit and
    the derived locals can't disagree.
  • Works locally, but GitHub CI has problems that are there for other
    branches too, hopefully not related
    (branch: delete-merged #2285).

Changes in v13:

  • Reworked --forked into a real ref-filter applied in apply_ref_filter()
    instead of a post-pass, so non-matching branches are never allocated.
  • Match exact --forked patterns on full refnames (only globs use the
    abbreviated upstream), and dropped the old helper machinery, forward
    declaration, and string_list in favor of a strvec.
  • Replaced the boolean parameters of
    delete_branches()/check_branch_commit() with a single unsigned int flags.
  • --prune-merged now collects candidates via filter_refs() rather than its
    own branch walk.
  • --prune-merged now takes its patterns as positional arguments
    (e.g. git branch --prune-merged origin/main 'feature*') instead of
    repeating the option.

Changes in v12:

  • Reworked --forked from a standalone action into a --list-mode filter.
  • Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
    options.
  • Dropped the bare-remote-name resolution for --forked, the argument is now
    a ref or a glob.

Changes in v11:

  • The flags now take a branch, not a remote. --forked and --prune-merged
    accept a literal upstream short name like origin/main or a wildmatch
    pattern like origin/. The old --all-remotes flag is gone, since origin/
    covers that case.
  • The prune guard now compares @{push} against @{upstream}. A branch is
    spared when these are equal. That is the trunk like case, such as local
    main tracking and pushing to origin/main, where "fully merged to
    upstream" cannot be told apart from "just pulled". Only branches that
    push somewhere other than their upstream, typically fork based topics,
    are candidates. The earlier /HEAD by name guard that the reviewer
    rejected is gone.
  • New --dry-run for --prune-merged.

Changes in v10:

  • --forked / --prune-merged now take a branch glob instead of a remote name
    — origin, origin/*, origin/release-- all work. This replaces the
    remote-only form and subsumes the old --all-remotes flag, which has been
    dropped.
  • New --dry-run for --prune-merged.

Changes in v9:

  • --force no longer has special meaning with --prune-merged; reachability
    is always enforced. Use git branch -D to delete an unmerged branch.
    Matches how git branch's other read/safe actions treat --force.
  • Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
  • Dropped the --prune-merged --force tests.

Changes in v8:

  • Delete only when the branch's work is actually reachable from its
    upstream
  • Skip branches whose upstream is gone (even with --force)
  • Simplified the internal safety flag to live in one place

Changes in v7:

  • --prune-merged now checks if a branch is merged into its own upstream
    first. If the upstream is gone, it checks against the remote's default
    branch instead. If neither exists, the branch is refused (use --force to
    delete anyway).

Changes in v6:

  • --prune-merged now measures merged-ness against the remote's default
    branch instead of the candidate's upstream — so the decision no longer
    depends on which branch happens to be checked out locally.
  • delete_branches() / check_branch_commit() gained a per-candidate override
    that lets a caller substitute a different "what counts as merged"
    reference (or skip the check). branch -d callers pass NULL and keep their
    existing semantics.
  • prune_merged_branches() resolves each candidate's push-remote HEAD and
    threads it through, so --prune-merged --all-remotes measures each
    candidate against its own remote rather than a single global reference.

Changes in v5:

  • Drop commit 'fetch: add --prune-merged'

Changes in v4:

  • Resolve each remote's HEAD and collect the targets into a
    protected_default_refs set in collect_forked_set.
  • In prune_merged_branches, skip a candidate when its upstream is a
    protected default ref and the local branch name matches the default
    branch's leaf name (so a local main tracking origin/main is spared, but a
    renamed trunk tracking origin/main is not).
  • Also skip when the candidate's push ref points at a protected default
    ref, so a topic branch configured to push to origin/main is never pruned.
  • Tests: spare the local default branch; only protect by matching leaf name
    (not by upstream alone); spare a branch whose push ref is the remote
    default.

Changes in v3:

  • s/remote-tracking refs/remote-tracking branches/g

Changes in v2:

  • The whole feature moved out of git fetch and into git branch. git fetch
    --prune-merged now just calls git branch --prune-merged after fetching.
  • The fetch.pruneLocalBranches and remote..pruneLocalBranches config
    options are gone, replaced by per-branch opt-out via
    branch..pruneMerged.
  • New git branch --forked lists local branches whose upstream
    lives on the given remote (read-only building block).
  • New git branch --prune-merged deletes those branches, but only
    if their tip is reachable from the upstream tracking ref; --force skips
    that safety check.
  • New git branch --all-remotes lets --forked/--prune-merged operate across
    every configured remote at once.
  • The currently checked-out branch in any worktree is always preserved.
  • branch..pruneMerged=false lets you exempt a branch (e.g. a
    long-running topic branch) even with --force; doesn't affect explicit git
    branch -d.
  • delete_branches() got a warn_only mode so bulk deletion prints a one-line
    warning per skipped branch instead of the noisy four-line hint that git
    branch -d shows.
  • New section in git-branch docs; git-fetch docs trimmed to just mention
    --prune-merged.
  • New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
    shrunk since most logic moved.

cc: "Kristoffer Haugsbakk" kristofferhaugsbakk@fastmail.com
cc: Johannes Sixt j6t@kdbg.org
cc: Phillip Wood phillip.wood123@gmail.com

@HaraldNordgren
HaraldNordgren force-pushed the fetch-prune-local-branches branch 4 times, most recently from 7bb8db6 to 3fce72f Compare May 1, 2026 18:19
@HaraldNordgren HaraldNordgren changed the title fetch: add fetch.pruneLocalBranches config fetch: add fetch.pruneBranches config May 1, 2026
@HaraldNordgren
HaraldNordgren force-pushed the fetch-prune-local-branches branch 4 times, most recently from 4d3e620 to 94014b8 Compare May 1, 2026 19:16
@HaraldNordgren
HaraldNordgren marked this pull request as ready for review May 1, 2026 19:20
@HaraldNordgren
HaraldNordgren force-pushed the fetch-prune-local-branches branch 2 times, most recently from 184a37a to 14e3085 Compare May 1, 2026 21:34
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.git.git.1777671337839.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v1

To fetch this version to local tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v1:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v1

@gitgitgadget-git

Copy link
Copy Markdown

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> Introduce a tri-state config option that, when --prune (or
> fetch.prune / remote.<name>.prune) removes a remote-tracking
> ref, also deletes local branches whose configured upstream is
> that ref.
>
> Values:
> - false (default): no change in behavior.
> - safe: delete only if the local tip is reachable from the
>   upstream tip, preserving any unpushed work.
> - force: delete unconditionally; recoverable only via reflog.
>
> The currently checked-out branch is always preserved.

I do like the feature that allows you to identify which local
branches are already merged and prune them.  It will help users keep
their local branch namespace clean.

I however do not like to see the feature tied to "fetch".  By this,
I do not mean I do not want an option to trigger the feature when
"git fetch" is run.  What I mean is that users should have an option
to prune merged branches without having to fetch first.  And you can
then optionally trigger that machinery from "git fetch".

Of course they aleady can do something silly like

    $ git branch -d $(git branch --list | sed -e 's/^..//')

and remove all the merged branches, but compared to what is
presented here, one thing missing is that you allow pruning the
local branches that are merged only to remote-tracking branches from
a single remote.

To break the feature down to make it easier to use by our users with
various needs and workflows, we would benefit from having a
collection of smaller features that can be composed, like these:

 * "git branch --forked <remote>" lists local branches that build on
   something taken from <remote>s.  The option can be given multiple
   times to make a union of the results from individual "--forked
   <remote>".

   - <remote> may be a name of a remote, e.g., "origin" to mean all
     the remote-tracking branches "refs/remotes/origin/*", 

   - <remote> may be "origin/master" to name a specific
     remote-tracking branch.

   - There may be other handy things to cover with <remote>, like
     "--all" that may act as if you listed all the available
     <remote> on the command line.

 * "git branch --prune-merged <remote>..." is a short-hand for "git
   branch -d $(git branch --forked <remote>...".

 * "git fetch/pull --prune-merged <remote>" can trigger "git branch
   --prune-merged <remote>" after "git fetch" successfully updates
   the remote-tracking branches, which should be equivalent to what
   you have here..

Some local branches that fork from remote and have their initial
round already merged may not want to be pruned, however.  You may
have multi-stage development plans for that topic, and you know
already the second phase would want to build on top of the initial
round, not a random version of the mainline with many topics from
other folks merged in.  So you'd rather want to keep the topic
branch around after your initial round has been merged to the
upstream before you start the second phase.  This is especially true
if your topic is designed to apply to an existing release (in other
words, a bugfix) and you want to keep the second and subsequent
rounds of the topic to be applicable to the same target version
without contaminating the topic with irrelevant features from others
that happened to have been developed and merged upstream around the
same time.

And we'd need to cater to their needs.  By this, I do not mean "they
do not have to use --prune-merged", but by giving them a way to say
"this branch should not be auto-pruned with --prune-merged".

@HaraldNordgren
HaraldNordgren force-pushed the fetch-prune-local-branches branch 7 times, most recently from dd4da62 to 66dac97 Compare May 4, 2026 18:14
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v2

To fetch this version to local tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v2:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v2

@gitgitgadget-git

Copy link
Copy Markdown

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

> I do like the feature that allows you to identify which local
> branches are already merged and prune them.  It will help users keep
> their local branch namespace clean.

Nice to hear!

> To break the feature down to make it easier to use by our users with
> various needs and workflows, we would benefit from having a
> collection of smaller features that can be composed, like these:

I gave it a shot to implement these, and then I ran it one some local repos
it works really nicely!


Harald

Comment thread Documentation/git-branch.adoc
@gitgitgadget-git

Copy link
Copy Markdown

User "Kristoffer Haugsbakk" <kristofferhaugsbakk@fastmail.com> has been added to the cc: list.

@HaraldNordgren
HaraldNordgren force-pushed the fetch-prune-local-branches branch from 66dac97 to 6462642 Compare May 5, 2026 07:17
Comment thread builtin/branch.c
Comment thread builtin/branch.c
Comment thread Documentation/git-branch.adoc
@gitgitgadget-git

Copy link
Copy Markdown

Phillip Wood wrote on the Git mailing list (how to reply to this email):

Hi Harald

I've looked through the first five patches and left a few comments and queries. I'll look at the last two at the start of next week so don't re-roll just yet, but my all means reply to the comments, especially where I've asked questions. So far I like what I've seen.

Thanks

Phillip

On 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:
> Delete branches that have already been merged on upstream.
> > Changes in v18:
> >   * Instead of keeping the whole chain of upstream branches, keep only the
>     ones an unmerged branch still needs. When a kept (merged) branch in turn
>     tracks a branch that is being deleted, clear its now-stale upstream
>     config.
>   * Rework spare_stacked_bases() to record the kept bases and, in a second
>     pass, clear the upstream of any whose own base is going away. Build the
>     to-delete list with strset_for_each_entry() instead of re-walking the
>     candidate array.
> > Changes in v17:
> >   * Keep a merged branch when another surviving branch still tracks it as its
>     upstream, so --delete-merged no longer deletes a branch out from under
>     one stacked on top of it.
>   * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into
>     their own commits.
> > Changes in v16:
> >   * Convert delete_merged_branches() to take an unsigned int flags argument
>     instead of separate quiet/dry_run booleans, matching delete_branches()
>   * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,
>     single strbuf_release after) instead of allocating and freeing it each
>     time
>   * Rewrite the --delete-merged tests as integration tests: branches that
>     land commits upstream, with deletion and the checked-out, upstream-gone,
>     and push-equals-upstream safety cases exercised together in one run and
>     output asserted via test_cmp
>   * Collapse the many per-aspect test repos into a single reused repo set up
>     by a setup_repo_for_delete_merged helper, and rename helpers off the old
>     pm_/prune naming
>   * Nest single-repo setup sequences in ( cd ... ) subshells instead of
>     prefixing every command with -C
> > Changes in v15:
> >   * Renamed --prune-merged to --delete-merged throughout. Not necessarily
>     final, but something to advance the discussion.
>   * --delete-merged now silently skips not-yet-merged branches instead of
>     warning.
>   * Initialized the delete_branches() flag locals where declared. Only force
>     stays deferred.
>   * delete_branches()/check_branch_commit() doc and code cleanups: redundant
>     branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
>     unreachable non-branch ref, and reworked --delete-merged doc wording.
>   * Broadened the --forked tests (local commits for realism, remote add -f,
>     --forked coverage), renamed the misleading trunk fixture, and replaced
>     the misnamed detached branch with git checkout --detach.
> > Changes in v14:
> >   * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
>     remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
>     check_branch_commit() reads, so it wrongly ran the merge check.
>   * Made flags the single source of truth in delete_branches() so the bit and
>     the derived locals can't disagree.
>   * Works locally, but GitHub CI has problems that are there for other
>     branches too, hopefully not related
>     (https://github.com/git/git/pull/2285).
> > Changes in v13:
> >   * Reworked --forked into a real ref-filter applied in apply_ref_filter()
>     instead of a post-pass, so non-matching branches are never allocated.
>   * Match exact --forked patterns on full refnames (only globs use the
>     abbreviated upstream), and dropped the old helper machinery, forward
>     declaration, and string_list in favor of a strvec.
>   * Replaced the boolean parameters of
>     delete_branches()/check_branch_commit() with a single unsigned int flags.
>   * --prune-merged now collects candidates via filter_refs() rather than its
>     own branch walk.
>   * --prune-merged now takes its patterns as positional arguments (e.g. git
>     branch --prune-merged origin/main 'feature*') instead of repeating the
>     option.
> > Changes in v12:
> >   * Reworked --forked from a standalone action into a --list-mode filter.
>   * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
>     options.
>   * Dropped the bare-remote-name resolution for --forked, the argument is now
>     a ref or a glob.
> > Changes in v11:
> >   * The flags now take a branch, not a remote. --forked and --prune-merged
>     accept a literal upstream short name like origin/main or a wildmatch
>     pattern like origin/. The old --all-remotes flag is gone, since origin/
>     covers that case.
>   * The prune guard now compares @{push} against @{upstream}. A branch is
>     spared when these are equal. That is the trunk like case, such as local
>     main tracking and pushing to origin/main, where "fully merged to
>     upstream" cannot be told apart from "just pulled". Only branches that
>     push somewhere other than their upstream, typically fork based topics,
>     are candidates. The earlier /HEAD by name guard that the reviewer
>     rejected is gone.
>   * New --dry-run for --prune-merged.
> > Changes in v10:
> >   * --forked / --prune-merged now take a branch glob instead of a remote name
>     — origin, origin/*, origin/release-- all work. This replaces the
>     remote-only form and subsumes the old --all-remotes flag, which has been
>     dropped.
>   * New --dry-run for --prune-merged.
> > Changes in v9:
> >   * --force no longer has special meaning with --prune-merged; reachability
>     is always enforced. Use git branch -D to delete an unmerged branch.
>     Matches how git branch's other read/safe actions treat --force.
>   * Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
>   * Dropped the --prune-merged --force tests.
> > Changes in v8:
> >   * Delete only when the branch's work is actually reachable from its
>     upstream
>   * Skip branches whose upstream is gone (even with --force)
>   * Simplified the internal safety flag to live in one place
> > Changes in v7:
> >   * --prune-merged now checks if a branch is merged into its own upstream
>     first. If the upstream is gone, it checks against the remote's default
>     branch instead. If neither exists, the branch is refused (use --force to
>     delete anyway).
> > Changes in v6:
> >   * --prune-merged now measures merged-ness against the remote's default
>     branch instead of the candidate's upstream — so the decision no longer
>     depends on which branch happens to be checked out locally.
>   * delete_branches() / check_branch_commit() gained a per-candidate override
>     that lets a caller substitute a different "what counts as merged"
>     reference (or skip the check). branch -d callers pass NULL and keep their
>     existing semantics.
>   * prune_merged_branches() resolves each candidate's push-remote HEAD and
>     threads it through, so --prune-merged --all-remotes measures each
>     candidate against its own remote rather than a single global reference.
> > Changes in v5:
> >   * Drop commit 'fetch: add --prune-merged'
> > Changes in v4:
> >   * Resolve each remote's HEAD and collect the targets into a
>     protected_default_refs set in collect_forked_set.
>   * In prune_merged_branches, skip a candidate when its upstream is a
>     protected default ref and the local branch name matches the default
>     branch's leaf name (so a local main tracking origin/main is spared, but a
>     renamed trunk tracking origin/main is not).
>   * Also skip when the candidate's push ref points at a protected default
>     ref, so a topic branch configured to push to origin/main is never pruned.
>   * Tests: spare the local default branch; only protect by matching leaf name
>     (not by upstream alone); spare a branch whose push ref is the remote
>     default.
> > Changes in v3:
> >   * s/remote-tracking refs/remote-tracking branches/g
> > Changes in v2:
> >   * The whole feature moved out of git fetch and into git branch. git fetch
>     --prune-merged now just calls git branch --prune-merged after fetching.
>   * The fetch.pruneLocalBranches and remote..pruneLocalBranches config
>     options are gone, replaced by per-branch opt-out via branch..pruneMerged.
>   * New git branch --forked lists local branches whose upstream lives on the
>     given remote (read-only building block).
>   * New git branch --prune-merged deletes those branches, but only if their
>     tip is reachable from the upstream tracking ref; --force skips that
>     safety check.
>   * New git branch --all-remotes lets --forked/--prune-merged operate across
>     every configured remote at once.
>   * The currently checked-out branch in any worktree is always preserved.
>   * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running
>     topic branch) even with --force; doesn't affect explicit git branch -d.
>   * delete_branches() got a warn_only mode so bulk deletion prints a one-line
>     warning per skipped branch instead of the noisy four-line hint that git
>     branch -d shows.
>   * New section in git-branch docs; git-fetch docs trimmed to just mention
>     --prune-merged.
>   * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
>     shrunk since most logic moved.
> > Harald Nordgren (7):
>    branch: add --forked filter for --list mode
>    branch: convert delete_branches() to a flags argument
>    branch: let delete_branches skip unmerged branches on bulk refusal
>    branch: prepare delete_branches for a bulk caller
>    branch: add --delete-merged <branch>
>    branch: add branch.<name>.deleteMerged opt-out
>    branch: add --dry-run for --delete-merged
> >   Documentation/config/branch.adoc |   7 +
>   Documentation/git-branch.adoc    |  48 ++++-
>   builtin/branch.c                 | 266 +++++++++++++++++++++---
>   ref-filter.c                     |  70 +++++++
>   ref-filter.h                     |  10 +
>   t/t3200-branch.sh                | 342 +++++++++++++++++++++++++++++++
>   6 files changed, 715 insertions(+), 28 deletions(-)
> > > base-commit: ab776a62a78576513ee121424adb19597fbb7613
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18
> Pull-Request: https://github.com/git/git/pull/2285
> > Range-diff vs v17:
> >   1:  d8cc17bd7f = 1:  3e29ff17bd branch: add --forked filter for --list mode
>   2:  d14b0403f0 = 2:  cdd4fea4a7 branch: convert delete_branches() to a flags argument
>   3:  ef2719dac3 = 3:  a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal
>   4:  80518f5d11 = 4:  a56d8fe93e branch: prepare delete_branches for a bulk caller
>   5:  46da7c8140 ! 5:  a84c555d99 branch: add --delete-merged <branch>
>       @@ Commit message
>            upstream. The work has already landed on the upstream they track,
>            so the local copy is no longer needed.
>        >       -    Three kinds of branches are not deleted:
>       +    A branch is not deleted when:
>        >       -      * any branch checked out in any worktree
>       -      * any branch whose upstream remote-tracking branch no longer
>       -        exists, since a missing upstream is not by itself a sign of
>       -        integration
>       -      * any branch whose push destination equals its upstream
>       -        (<branch>@{push} is the same as <branch>@{upstream}), such as
>       -        a local "main" that tracks and pushes to "origin/main". Right
>       -        after a pull it just looks "fully merged", so it is kept. Only
>       -        branches that push somewhere other than their upstream,
>       -        typically topics in a fork workflow, are candidates.
>       +      * it is checked out in any worktree
>       +      * its upstream remote-tracking branch no longer exists, since a
>       +        missing upstream is not by itself a sign of integration
>       +      * its push destination equals its upstream (<branch>@{push} is
>       +        the same as <branch>@{upstream}), such as a local "main" that
>       +        tracks and pushes to "origin/main". Right after a pull it just
>       +        looks "fully merged", so it is kept. Only branches that push
>       +        somewhere other than their upstream, typically topics in a fork
>       +        workflow, are candidates.
>        >            A branch whose work is not yet merged into its upstream is silently
>            skipped, so one unmerged topic does not abort the whole sweep.
>        >            A branch that another, surviving branch tracks as its upstream is
>            also kept, so a branch is never deleted out from under one stacked
>       -    on top of it. Sparing such a base can in turn protect its own
>       -    upstream, so the check repeats until the set stops changing.
>       +    on top of it. Such a kept branch is itself merged, so when its own
>       +    upstream is being deleted, clear its now-stale upstream config.
>        >            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>        >       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
>        +silently skipped. Delete it with `git branch -D` if you want to
>        +remove it anyway.
>        ++
>       -+A branch that another, surviving branch still tracks as its upstream
>       -+is kept, so a branch is never deleted out from under one stacked on
>       -+top of it.
>       ++A branch that another, surviving branch tracks as its upstream is
>       ++kept, so a branch is never deleted out from under one stacked on top
>       ++of it. If that kept branch in turn tracks a branch that is being
>       ++deleted, its now-stale upstream configuration is cleared.
>        +
>         `-v`::
>         `-vv`::
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>         	return 0;
>         }
>         >       -+static int collect_upstream(const struct reference *ref, void *cb_data)
>       -+{
>       -+	struct string_list *upstreams = cb_data;
>       -+	struct branch *branch = branch_get(ref->name);
>       -+	const char *upstream = branch_get_upstream(branch, NULL);
>       ++struct spare_data {
>       ++	struct strset *deletable;
>       ++	struct strset *spared;
>       ++};
>        +
>       -+	string_list_append(upstreams, ref->name)->util =
>       -+		xstrdup_or_null(upstream);
>       ++/*
>       ++ * A surviving branch stacked on a deletion candidate would lose its
>       ++ * upstream, so drop that candidate from the delete set and remember it
>       ++ * in "spared" so its own upstream can be tidied up afterwards.
>       ++ */
>       ++static int spare_stacked_base(const struct reference *ref, void *cb_data)
>       ++{
>       ++	struct spare_data *data = cb_data;
>       ++	struct branch *branch;
>       ++	const char *upstream, *up_short;
>       ++
>       ++	if (strset_contains(data->deletable, ref->name))
>       ++		return 0;
>       ++	branch = branch_get(ref->name);
>       ++	upstream = branch_get_upstream(branch, NULL);
>       ++	if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       ++	    !strset_contains(data->deletable, up_short))
>       ++		return 0;
>       ++
>       ++	strset_remove(data->deletable, up_short);
>       ++	strset_add(data->spared, up_short);
>        +	return 0;
>        +}
>        +
>        +/*
>       -+ * Keep any branch that another, surviving branch tracks as its
>       -+ * upstream, so we never delete a branch out from under one stacked on
>       -+ * top of it.  Sparing a branch makes it a survivor whose own upstream
>       -+ * then needs the same protection, so repeat until nothing changes.
>       ++ * Keep any branch that a surviving branch tracks as its upstream, so we
>       ++ * never delete a branch out from under one stacked on top of it.  Such a
>       ++ * base is itself merged, so when its own upstream is also going away
>       ++ * (no surviving branch tracks it), clear the base's now-stale upstream.
>        + */
>        +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
>        +{
>       -+	struct string_list upstreams = STRING_LIST_INIT_DUP;
>       -+	struct string_list_item *item;
>       -+	bool spared;
>       -+
>       -+	refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
>       -+	do {
>       -+		spared = false;
>       -+		for_each_string_list_item(item, &upstreams) {
>       -+			const char *up = item->util, *up_short;
>       -+
>       -+			if (!up || strset_contains(deletable, item->string))
>       -+				continue;
>       -+			if (!skip_prefix(up, "refs/heads/", &up_short) ||
>       -+			    !strset_contains(deletable, up_short))
>       -+				continue;
>       -+
>       -+			strset_remove(deletable, up_short);
>       -+			spared = true;
>       -+		}
>       -+	} while (spared);
>       -+
>       -+	string_list_clear(&upstreams, 1);
>       ++	struct strset spared = STRSET_INIT;
>       ++	struct spare_data data = { .deletable = deletable, .spared = &spared };
>       ++	struct strbuf key = STRBUF_INIT;
>       ++	struct hashmap_iter iter;
>       ++	struct strmap_entry *entry;
>       ++
>       ++	refs_for_each_branch_ref(refs, spare_stacked_base, &data);
>       ++
>       ++	strset_for_each_entry(&spared, &iter, entry) {
>       ++		struct branch *branch = branch_get(entry->key);
>       ++		const char *upstream = branch_get_upstream(branch, NULL);
>       ++		const char *up_short;
>       ++
>       ++		if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       ++		    !strset_contains(deletable, up_short))
>       ++			continue;
>       ++
>       ++		strbuf_reset(&key);
>       ++		strbuf_addf(&key, "branch.%s.merge", branch->name);
>       ++		repo_config_set_gently(the_repository, key.buf, NULL);
>       ++		strbuf_reset(&key);
>       ++		strbuf_addf(&key, "branch.%s.remote", branch->name);
>       ++		repo_config_set_gently(the_repository, key.buf, NULL);
>       ++	}
>       ++
>       ++	strbuf_release(&key);
>       ++	strset_clear(&spared);
>        +}
>        +
>        +static int delete_merged_branches(int argc, const char **argv,
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>        +	struct ref_array candidates = { 0 };
>        +	struct strset deletable = STRSET_INIT;
>        +	struct strvec to_delete = STRVEC_INIT;
>       ++	struct hashmap_iter iter;
>       ++	struct strmap_entry *entry;
>        +	int i, ret = 0;
>        +
>        +	if (!argc)
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>        +
>        +	spare_stacked_bases(refs, &deletable);
>        +
>       -+	for (i = 0; i < candidates.nr; i++) {
>       -+		const char *short_name;
>       -+
>       -+		if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
>       -+				&short_name) &&
>       -+		    strset_contains(&deletable, short_name))
>       -+			strvec_push(&to_delete, short_name);
>       -+	}
>       ++	strset_for_each_entry(&deletable, &iter, entry)
>       ++		strvec_push(&to_delete, entry->key);
>        +
>        +	if (to_delete.nr)
>        +		ret = delete_branches(to_delete.nr, to_delete.v,
>       @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
>        +		git checkout --detach
>        +	) &&
>        +
>       ++	git -C repo branch --dry-run --delete-merged origin/next >out &&
>       ++	test_grep ! "feature" out &&
>       ++
>        +	git -C repo branch --delete-merged origin/next 2>err &&
>        +
>        +	test_must_be_empty err &&
>        +	git -C repo rev-parse --verify refs/heads/feature &&
>       -+	git -C repo rev-parse --verify refs/heads/topic
>       ++	git -C repo rev-parse --verify refs/heads/topic &&
>       ++	echo origin/next >expect &&
>       ++	git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&
>       ++	test_cmp expect actual &&
>       ++	echo feature >expect &&
>       ++	git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&
>       ++	test_cmp expect actual
>        +'
>        +
>        +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '
>       @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
>        +	EOF
>        +	test_cmp expect actual
>        +'
>       ++
>       ++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '
>       ++	test_when_finished "rm -rf repo" &&
>       ++	setup_repo_for_delete_merged &&
>       ++	(
>       ++		cd repo &&
>       ++		git branch lower origin/next &&
>       ++		git branch --set-upstream-to=origin/next lower &&
>       ++		git branch mid origin/next &&
>       ++		git branch --set-upstream-to=lower mid &&
>       ++		git checkout -b tip mid &&
>       ++		git commit --allow-empty -m "tip work" &&
>       ++		git branch --set-upstream-to=mid tip &&
>       ++		git checkout --detach
>       ++	) &&
>       ++
>       ++	git -C repo branch --delete-merged origin/next lower &&
>       ++
>       ++	test_must_fail git -C repo rev-parse --verify refs/heads/lower &&
>       ++	git -C repo rev-parse --verify refs/heads/mid &&
>       ++	test_must_fail git -C repo rev-parse mid@{upstream} &&
>       ++	echo mid >expect &&
>       ++	git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&
>       ++	test_cmp expect actual
>       ++'
>        +
>         test_done
>   6:  27903fbb1d ! 6:  d52d717b70 branch: add branch.<name>.deleteMerged opt-out
>       @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
>         	struct strset deletable = STRSET_INIT;
>         	struct strvec to_delete = STRVEC_INIT;
>        +	struct strbuf key = STRBUF_INIT;
>       + 	struct hashmap_iter iter;
>       + 	struct strmap_entry *entry;
>        +	bool quiet = flags & DELETE_BRANCH_QUIET;
>         	int i, ret = 0;
>         >       @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
>         	ref_array_clear(&candidates);
>        >         ## t/t3200-branch.sh ##
>       -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow
>         	test_cmp expect actual
>         '
>         >   7:  49c1bcf1fb ! 7:  8d0323f4b3 branch: add --dry-run for --delete-merged
>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
>         >         DESCRIPTION
>         -----------
>       -@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
>       - is kept, so a branch is never deleted out from under one stacked on
>       - top of it.
>       +@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
>       + of it. If that kept branch in turn tracks a branch that is being
>       + deleted, its now-stale upstream configuration is cleared.
>         >        +`--dry-run`::
>        +	With `--delete-merged`, print which branches would be
> 

@mohommedsmohommeds-source

Copy link
Copy Markdown

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged to their tracked
remote-tracking branches.

Expecting a reroll.
cf. <CAHwyqnWspUTSnqmkMyXtWuAnENDSzrRLhhUR=Ljtt1xer3tphA@mail.gmail.com>
source: <pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com>

@@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

Hi Harald

On 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > With --dry-run, --delete-merged prints the local branches it would
> delete, one "Would delete branch <name>" line each, and exits
> without touching any ref. The same filtering applies, so the output
> is exactly the set that the real run would delete.

The same filtering as what? I think something like

"git branch --dry-run --delete-merged ..." prints one line per ref that would be deleted without modifing any refs.

would be sufficient
> --dry-run is only meaningful together with --delete-merged and is
> rejected otherwise.

Good
> @@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
>   		free(target);
>   	}
>   > -	if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
> +	if (!dry_run &&
> +	    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
>   		ret = 1;
>   >   	for_each_string_list_item(item, &refs_to_delete) {
>   		char *describe_ref = item->util;
>   		char *name = item->string;
> -		if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> +		if (dry_run) {
> +			if (!quiet)

This matches what we do without '--dry-run' but what use is '--dry-run --quiet' if it does not print anything?

> +				printf(remote_branch
> +					? _("Would delete remote-tracking branch %s (was %s).\n")
> +					: _("Would delete branch %s (was %s).\n"),
> +					name + branch_name_pos, describe_ref);
> +		} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index b7595610d9..cddcde341d 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest
>   	) &&
>   	sha=$(git -C repo rev-parse --short merged) &&
>   > -	git -C repo branch --delete-merged origin/next >actual 2>&1 &&
> +	git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&
> +	echo "Would delete branch merged (was $sha)." >expect &&
> +	test_cmp expect actual &&
> +	git -C repo rev-parse --verify refs/heads/merged &&
>   > +	git -C repo branch --delete-merged origin/next >actual 2>&1 &&

I was wondering why the diff shows the line above being deleted and then added, it is because previously there was a blank line after it. The test for --dry-run looks good.
>   	echo "Deleted branch merged (was $sha)." >expect &&
>   	test_cmp expect actual &&
>   	git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
> @@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" '
>   	test_must_fail git -C repo rev-parse --verify refs/heads/kept
>   '
>   > +test_expect_success '--dry-run without --delete-merged is rejected' '
> +	test_must_fail git -C forked branch --dry-run 2>err &&
> +	test_grep "requires --delete-merged" err

Nice

Thanks

Phillip

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

> This matches what we do without '--dry-run' but what use is '--dry-run
> --quiet' if it does not print anything?

Yeah, it's a bit weird. What do you suggest we do instead, reject the
combination or turn it into a no-op?


Harald

Comment thread Documentation/config/branch.adoc
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19

To fetch this version to local tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged to their tracked
remote-tracking branches.

Needs review.
source: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged to their tracked
remote-tracking branches.

Needs review.
source: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>

@@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:

> +struct spare_data {
> +	struct strset *deletable;
> +	struct strset *spared;
> +};
> +
> +/*
> + * A surviving branch stacked on a deletion candidate would lose its
> + * upstream, so drop that candidate from the delete set and remember it
> + * in "spared" so its own upstream can be tidied up afterwards.
> + */
> +static int spare_stacked_base(const struct reference *ref, void *cb_data)
> +{
> +	struct spare_data *data = cb_data;
> +	struct branch *branch;
> +	const char *upstream, *up_short;
> +
> +	if (strset_contains(data->deletable, ref->name))
> +		return 0;
> +	branch = branch_get(ref->name);
> +	upstream = branch_get_upstream(branch, NULL);
> +	if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
> +	    !strset_contains(data->deletable, up_short))
> +		return 0;
> +
> +	strset_remove(data->deletable, up_short);
> +	strset_add(data->spared, up_short);
> +	return 0;
> +}
> +
> +/*
> + * Keep any branch that a surviving branch tracks as its upstream, so we
> + * never delete a branch out from under one stacked on top of it.  Such a
> + * base is itself merged, so when its own upstream is also going away
> + * (no surviving branch tracks it), clear the base's now-stale upstream.
> + */
> +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
> +{
> +	struct strset spared = STRSET_INIT;
> +	struct spare_data data = { .deletable = deletable, .spared = &spared };
> +	struct strbuf key = STRBUF_INIT;
> +	struct hashmap_iter iter;
> +	struct strmap_entry *entry;
> +
> +	refs_for_each_branch_ref(refs, spare_stacked_base, &data);

Hmph.  Wouldn't this implicitly make whether a stacked branch is
spared or has its upstream configuration cleared depends on the
order in which the branches are visited by the callback function of
refs_for_each_branch_ref(), which presumably is alphabetical?

For example, if 'a_tip' (unmerged) tracks 'b_mid' (merged), which in
turn tracks 'c_lower' (merged), visiting them in alphabetical order
('a_tip', 'b_mid', 'c_lower') would spare both 'b_mid' and
'c_lower'.  If they, however, were named 'tip' (unmerged), which
tracks 'mid' (merged), which in turn tracks 'lower' (merged), they
would be visited in the order 'lower', 'mid', 'tip'.  This would
result in 'lower' being deleted and 'mid' being spared with its
upstream configuration cleared, even though the relationship among
these three branches is exactly the same.

Since the branches are visited in a fixed alphabetical order, it
might not be an unpredictable order that yields unrepeatable
results.  Nonetheless, the behavior should be consistent and
independent of branch names, as long as the inter-relationship
among the branches involved is identical, no?

Or am I grossly misreading the code?

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

I think I can fix this with

```
    -+  if (strset_contains(data->deletable, ref->name))
    ++  if (strset_contains(data->deletable, ref->name) ||
    ++      strset_contains(data->spared, ref->name))
```

I used your example to write a test about it as well.


Harald

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Harald Nordgren <haraldnordgren@gmail.com> writes:

> I think I can fix this with
>
> ```
>     -+  if (strset_contains(data->deletable, ref->name))
>     ++  if (strset_contains(data->deletable, ref->name) ||
>     ++      strset_contains(data->spared, ref->name))
> ```
>
> I used your example to write a test about it as well.
>
>
> Harald

I do not claim that the single example I gave covers all the issues
that arise from failing to analyze the dependency graph, or from
attempting to solve the problem sequentially, which makes the
solution depend on the order in which branches are visited.

I have a suspicion that it may be unavoidable to employ a multi-pass
approach that iteratively identifies all branches transitively
needed by any surviving branch, though that is merely a hunch,
unsupported by any proof.

Thanks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phillip Wood wrote on the Git mailing list (how to reply to this email):

On 19/07/2026 20:22, Junio C Hamano wrote:
> Harald Nordgren <haraldnordgren@gmail.com> writes:
> >> I think I can fix this with
>>
>> ```
>>      -+  if (strset_contains(data->deletable, ref->name))
>>      ++  if (strset_contains(data->deletable, ref->name) ||
>>      ++      strset_contains(data->spared, ref->name))
>> ```
>>
>> I used your example to write a test about it as well.
>>
>>
>> Harald
> > I do not claim that the single example I gave covers all the issues
> that arise from failing to analyze the dependency graph, or from
> attempting to solve the problem sequentially, which makes the
> solution depend on the order in which branches are visited.
> > I have a suspicion that it may be unavoidable to employ a multi-pass
> approach that iteratively identifies all branches transitively
> needed by any surviving branch, though that is merely a hunch,
> unsupported by any proof.

I think it would certainly be easier to reason about if we did not mutate data->deletable in spare_stacked_base() in removed the spared branches with

	strset_for_each_entry(&spared, &iter, entry)
		strset_remove(&deletable, entry);

after the call to refs_for_each_branch_ref() returns in spare_stacked_branches().

I'll try and have a proper look at this iteration in the comming week.

Thanks

Phillip

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged into their tracked
remote-tracking branches.

Needs review.
cf. <xmqqtspvptqc.fsf@gitster.g>
source: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>

Comment thread Documentation/git-branch.adoc
@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged into their tracked
remote-tracking branches.

Expecting a reroll.
cf. <xmqqtspvptqc.fsf@gitster.g>
cf. <CAHwyqnXdaPeO12+p=_+_ttrknV0-VqTMnH-suS66yZ4stsBKnQ@mail.gmail.com>
source: <pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com>

Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.

It is an ordinary list filter, so it combines with the others:

    git branch --merged origin/main --forked 'origin/*'

lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.

This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() takes separate force and quiet parameters, while
check_branch_commit() takes force. The next commits would grow this
collection further. Replace them with a single unsigned flags argument
and an enum.

Test the FORCE and QUIET bits directly from flags at each use site so
that mutating or forwarding flags cannot leave cached values stale.

No change in behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Add a skip-unmerged mode to delete_branches() and check_branch_commit()
so a bulk caller can silently skip branches that are not fully merged
and carry on, rather than erroring with the "use 'git branch -D'"
advice that the plain "git branch -d" path emits. Existing callers are
unaffected.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() a new mode for the upcoming --delete-merged
caller that checks whether a branch is merged into its upstream without
falling back to HEAD when there is no upstream. Existing callers keep
their current behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
    git branch (--delete-merged <branch>)... [<pattern>...]

deletes local branches matching the optional patterns when their
configured upstream matches one of the --delete-merged arguments and
their tip is reachable from that upstream. The work has already landed
on the upstream they track, so the local copy is no longer needed.

The option can be repeated to widen the upstream match. Keeping the
candidate patterns as positional arguments lets users bound the set of
local branches that may be deleted independently of the upstream
selection.

A branch is not deleted when:

  * it is checked out in any worktree
  * its configured upstream ref no longer exists, since a missing
    upstream is not by itself a sign of integration
  * pushing it by name to the remote configured by
    branch.<name>.remote would update its upstream, as determined by
    mapping the branch ref through that remote's fetch refspec. For
    example, a local "main" that tracks "origin/main" is kept even when
    remote.pushDefault names a fork. Right after a pull it merely looks
    fully merged.

A branch whose work is not yet merged into its upstream is silently
skipped, so one unmerged topic does not abort the whole sweep.

A branch that a surviving branch depends on through a chain of local
upstreams is also kept, so no branch is deleted out from under stacked
work. Collect this transitive set without changing the candidate set
during ref iteration: walk upstream chains from surviving branches,
visit each branch at most once, and remove the collected bases only
after the iteration completes. This makes the result independent of
ref iteration order without repeated full scans.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.

Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
"git branch --dry-run --delete-merged ..." prints one line per ref that
would be deleted without modifying refs or branch configuration.

--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com

To fetch this version into FETCH_HEAD:

git fetch https://github.com/gitgitgadget/git/ pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v20

To fetch this version to local tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v20:

git fetch --no-tags https://github.com/gitgitgadget/git/ tag pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v20

@gitgitgadget-git

Copy link
Copy Markdown

Phillip Wood wrote on the Git mailing list (how to reply to this email):

Hi Harald

On 22/07/2026 08:10, Harald Nordgren via GitGitGadget wrote:
> Delete branches that have already been merged on upstream.
> > Changes in v20:
> >   * Protect branches transitively required by a surviving local upstream
>     stack. Traverse upstream chains once and defer delete-set mutation until
>     traversal completes.
>   * Make stacked-branch handling independent of ref iteration order and
>     update the documentation accordingly.
>   * Clarify variable names with regards to branch names (short) to reduce
>     confusion.

I'm having a hard time reading the range diff due to the renaming of the members of "struct stacked_branch_data". Can you explain what has changed in the logic to protect branches that are upstreams of unmerged branches and why please? In particular why wasn't sufficient to stop removing the members of "spared" from "deletable" in spare_stacked_base() and remove them after refs_for_each_branch_ref() has returned instead.

Thanks

Phillip

> Range-diff vs v19:
> >   1:  562648132d = 1:  51c1c9d075 branch: add --forked filter for --list mode
>   2:  c7ebd9344c = 2:  711574b2e5 branch: convert delete_branches() to a flags argument
>   3:  0c4f3358e3 = 3:  47c5975dc7 branch: let delete_branches skip unmerged branches on bulk refusal
>   4:  64a202526a = 4:  46268acec5 branch: prepare delete_branches for a bulk caller
>   5:  a6caa5b397 ! 5:  ef9f57e735 branch: add --delete-merged <branch>
>       @@ Commit message
>            A branch whose work is not yet merged into its upstream is silently
>            skipped, so one unmerged topic does not abort the whole sweep.
>        >       -    A branch that another, surviving branch tracks as its upstream is
>       -    also kept, so a branch is never deleted out from under one stacked
>       -    on top of it. Such a kept branch is itself merged, so when its own
>       -    upstream is being deleted, clear its now-stale upstream config.
>       +    A branch that a surviving branch depends on through a chain of local
>       +    upstreams is also kept, so no branch is deleted out from under stacked
>       +    work. Collect this transitive set without changing the candidate set
>       +    during ref iteration: walk upstream chains from surviving branches,
>       +    visit each branch at most once, and remove the collected bases only
>       +    after the iteration completes. This makes the result independent of
>       +    ref iteration order without repeated full scans.
>        >            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>        >       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
>        +silently skipped. Delete it with `git branch -D` if you want to
>        +remove it anyway.
>        ++
>       -+A branch that another, surviving branch tracks as its upstream is
>       -+kept, so a branch is never deleted out from under one stacked on top
>       -+of it. If that kept branch in turn tracks a branch that is being
>       -+deleted, its now-stale upstream configuration is cleared.
>       ++A branch that a surviving branch depends on through a chain of local
>       ++upstreams is kept, so a branch is never deleted out from under stacked
>       ++work.
>        +
>         `-v`::
>         `-vv`::
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>         	return 0;
>         }
>         >       -+struct spare_data {
>       -+	struct strset *deletable;
>       -+	struct strset *spared;
>       ++struct stacked_branch_data {
>       ++	struct strset *deletable_branch_names;
>       ++	struct strset *protected_branch_names;
>       ++	struct strset *visited_branch_names;
>        +};
>        +
>       -+/*
>       -+ * A surviving branch stacked on a deletion candidate would lose its
>       -+ * upstream, so drop that candidate from the delete set and remember it
>       -+ * in "spared" so its own upstream can be tidied up afterwards.
>       -+ */
>       -+static int spare_stacked_base(const struct reference *ref, void *cb_data)
>       ++static int collect_stacked_branch_bases(const struct reference *ref,
>       ++					void *cb_data)
>        +{
>       -+	struct spare_data *data = cb_data;
>       -+	struct branch *branch;
>       -+	const char *upstream, *up_short;
>       ++	struct stacked_branch_data *data = cb_data;
>       ++	const char *branch_name;
>        +
>       -+	if (strset_contains(data->deletable, ref->name))
>       -+		return 0;
>       -+	branch = branch_get(ref->name);
>       -+	upstream = branch_get_upstream(branch, NULL);
>       -+	if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       -+	    !strset_contains(data->deletable, up_short))
>       ++	if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
>       ++		BUG("expected local branch ref, got '%s'", ref->name);
>       ++	if (strset_contains(data->deletable_branch_names, branch_name))
>        +		return 0;
>        +
>       -+	strset_remove(data->deletable, up_short);
>       -+	strset_add(data->spared, up_short);
>       ++	while (strset_add(data->visited_branch_names, branch_name)) {
>       ++		struct branch *branch = branch_get(branch_name);
>       ++		const char *upstream_refname = branch_get_upstream(branch, NULL);
>       ++		const char *upstream_branch_name;
>       ++
>       ++		if (!upstream_refname ||
>       ++		    !skip_prefix(upstream_refname, "refs/heads/",
>       ++				 &upstream_branch_name) ||
>       ++		    !strset_contains(data->deletable_branch_names,
>       ++				    upstream_branch_name))
>       ++			break;
>       ++
>       ++		strset_add(data->protected_branch_names, upstream_branch_name);
>       ++		branch_name = upstream_branch_name;
>       ++	}
>       ++
>        +	return 0;
>        +}
>        +
>       -+/*
>       -+ * Keep any branch that a surviving branch tracks as its upstream, so we
>       -+ * never delete a branch out from under one stacked on top of it.  Such a
>       -+ * base is itself merged, so when its own upstream is also going away
>       -+ * (no surviving branch tracks it), clear the base's now-stale upstream.
>       -+ */
>       -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
>       ++static void protect_stacked_branch_bases(struct ref_store *refs,
>       ++					 struct strset *deletable_branch_names)
>        +{
>       -+	struct strset spared = STRSET_INIT;
>       -+	struct spare_data data = { .deletable = deletable, .spared = &spared };
>       -+	struct strbuf key = STRBUF_INIT;
>       ++	struct strset protected_branch_names = STRSET_INIT;
>       ++	struct strset visited_branch_names = STRSET_INIT;
>       ++	struct stacked_branch_data data = {
>       ++		.deletable_branch_names = deletable_branch_names,
>       ++		.protected_branch_names = &protected_branch_names,
>       ++		.visited_branch_names = &visited_branch_names,
>       ++	};
>       ++	struct refs_for_each_ref_options opts = {
>       ++		.prefix = "refs/heads/",
>       ++	};
>        +	struct hashmap_iter iter;
>        +	struct strmap_entry *entry;
>        +
>       -+	refs_for_each_branch_ref(refs, spare_stacked_base, &data);
>       -+
>       -+	strset_for_each_entry(&spared, &iter, entry) {
>       -+		struct branch *branch = branch_get(entry->key);
>       -+		const char *upstream = branch_get_upstream(branch, NULL);
>       -+		const char *up_short;
>       -+
>       -+		if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       -+		    !strset_contains(deletable, up_short))
>       -+			continue;
>       ++	refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);
>        +
>       -+		strbuf_reset(&key);
>       -+		strbuf_addf(&key, "branch.%s.merge", branch->name);
>       -+		repo_config_set_gently(the_repository, key.buf, NULL);
>       -+		strbuf_reset(&key);
>       -+		strbuf_addf(&key, "branch.%s.remote", branch->name);
>       -+		repo_config_set_gently(the_repository, key.buf, NULL);
>       -+	}
>       ++	strset_for_each_entry(&protected_branch_names, &iter, entry)
>       ++		strset_remove(deletable_branch_names, entry->key);
>        +
>       -+	strbuf_release(&key);
>       -+	strset_clear(&spared);
>       ++	strset_clear(&visited_branch_names);
>       ++	strset_clear(&protected_branch_names);
>        +}
>        +
>        +static int branch_pushes_to_upstream(struct branch *branch,
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>        +	struct ref_store *refs = get_main_ref_store(the_repository);
>        +	struct ref_filter filter = REF_FILTER_INIT;
>        +	struct ref_array candidates = { 0 };
>       -+	struct strset deletable = STRSET_INIT;
>       -+	struct strvec to_delete = STRVEC_INIT;
>       ++	struct strset deletable_branch_names = STRSET_INIT;
>       ++	struct strvec branches_to_delete = STRVEC_INIT;
>        +	struct hashmap_iter iter;
>        +	struct strmap_entry *entry;
>        +	size_t i;
>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
>        +	filter_refs(&candidates, &filter, filter.kind);
>        +
>        +	for (i = 0; i < (size_t)candidates.nr; i++) {
>       -+		const char *full_name = candidates.items[i]->refname;
>       -+		const char *short_name;
>       ++		const char *branch_refname = candidates.items[i]->refname;
>       ++		const char *branch_name;
>        +		struct branch *branch;
>       -+		const char *upstream;
>       ++		const char *upstream_refname;
>        +
>       -+		if (!skip_prefix(full_name, "refs/heads/", &short_name))
>       -+			BUG("filter returned non-branch ref '%s'", full_name);
>       -+		if (branch_checked_out(full_name))
>       ++		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
>       ++			BUG("filter returned non-branch ref '%s'", branch_refname);
>       ++		if (branch_checked_out(branch_refname))
>        +			continue;
>        +
>       -+		branch = branch_get(short_name);
>       -+		upstream = branch_get_upstream(branch, NULL);
>       -+		if (!upstream || !refs_ref_exists(refs, upstream))
>       ++		branch = branch_get(branch_name);
>       ++		upstream_refname = branch_get_upstream(branch, NULL);
>       ++		if (!upstream_refname || !refs_ref_exists(refs, upstream_refname))
>        +			continue;
>       -+		if (branch_pushes_to_upstream(branch, upstream))
>       ++		if (branch_pushes_to_upstream(branch, upstream_refname))
>        +			continue;
>       -+		if (check_branch_commit(short_name, short_name,
>       ++		if (check_branch_commit(branch_name, branch_name,
>        +					&candidates.items[i]->objectname, NULL,
>        +					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
>        +			continue;
>        +
>       -+		strset_add(&deletable, short_name);
>       ++		strset_add(&deletable_branch_names, branch_name);
>        +	}
>        +
>       -+	spare_stacked_bases(refs, &deletable);
>       ++	protect_stacked_branch_bases(refs, &deletable_branch_names);
>        +
>       -+	strset_for_each_entry(&deletable, &iter, entry)
>       -+		strvec_push(&to_delete, entry->key);
>       ++	strset_for_each_entry(&deletable_branch_names, &iter, entry)
>       ++		strvec_push(&branches_to_delete, entry->key);
>        +
>       -+	if (to_delete.nr)
>       -+		ret = delete_branches(to_delete.nr, to_delete.v,
>       ++	if (branches_to_delete.nr)
>       ++		ret = delete_branches(branches_to_delete.nr, branches_to_delete.v,
>        +				      FILTER_REFS_BRANCHES,
>        +				      DELETE_BRANCH_SKIP_UNMERGED |
>        +				      DELETE_BRANCH_NO_HEAD_FALLBACK |
>        +				      flags);
>        +
>       -+	strvec_clear(&to_delete);
>       -+	strset_clear(&deletable);
>       ++	strvec_clear(&branches_to_delete);
>       ++	strset_clear(&deletable_branch_names);
>        +	ref_array_clear(&candidates);
>        +	ref_filter_clear(&filter);
>        +	return ret;
>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
>        +	)
>        +'
>        +
>       -+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '
>       ++test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '
>        +	setup_repo_for_delete_merged &&
>        +	(
>        +		cd repo &&
>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
>        +		git commit --allow-empty -m "tip work" &&
>        +
>        +		git branch --delete-merged origin/next \
>       -+			--delete-merged lower &&
>       ++			--delete-merged lower >actual 2>&1 &&
>       ++		test_must_be_empty actual &&
>        +
>        +		check_branches <<-\EOF &&
>       ++		lower
>        +		main
>        +		mid
>        +		tip
>        +		EOF
>        +
>       -+		git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
>       ++		git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
>        +		cat >expect <<-\EOF &&
>       ++		branch.lower.remote origin
>       ++		branch.lower.merge refs/heads/next
>       ++		branch.mid.remote .
>       ++		branch.mid.merge refs/heads/lower
>        +		branch.tip.remote .
>        +		branch.tip.merge refs/heads/mid
>        +		EOF
>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
>        +	)
>        +'
>        +
>       ++test_expect_success '--delete-merged result is independent of stacked branch names' '
>       ++	setup_repo_for_delete_merged &&
>       ++	(
>       ++		cd repo &&
>       ++		git branch c-lower origin/next --track &&
>       ++		git branch b-mid c-lower --track &&
>       ++		git checkout -b a-tip b-mid --track &&
>       ++		git commit --allow-empty -m "tip work" &&
>       ++
>       ++		git branch --delete-merged origin/next \
>       ++			--delete-merged "c-*" &&
>       ++
>       ++		check_branches <<-\EOF &&
>       ++		a-tip
>       ++		b-mid
>       ++		c-lower
>       ++		main
>       ++		EOF
>       ++
>       ++		git branch --delete-merged origin/next \
>       ++			--delete-merged "c-*" >actual 2>&1 &&
>       ++		test_must_be_empty actual &&
>       ++
>       ++		check_branches <<-\EOF
>       ++		a-tip
>       ++		b-mid
>       ++		c-lower
>       ++		main
>       ++		EOF
>       ++	)
>       ++'
>       ++
>        +test_expect_success '--delete-merged requires a value' '
>        +	test_must_fail git -C forked branch --delete-merged 2>err &&
>        +	test_grep "requires a value" err
>   6:  734d27c908 ! 6:  fa70108611 branch: add branch.<name>.deleteMerged opt-out
>       @@ Documentation/git-branch.adoc: A branch is not deleted when:
>         ## builtin/branch.c ##
>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
>         	struct ref_array candidates = { 0 };
>       - 	struct strset deletable = STRSET_INIT;
>       - 	struct strvec to_delete = STRVEC_INIT;
>       + 	struct strset deletable_branch_names = STRSET_INIT;
>       + 	struct strvec branches_to_delete = STRVEC_INIT;
>        +	struct strbuf key = STRBUF_INIT;
>         	struct hashmap_iter iter;
>         	struct strmap_entry *entry;
>         	size_t i;
>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
>       - 		const char *short_name;
>       + 		const char *branch_name;
>         		struct branch *branch;
>       - 		const char *upstream;
>       + 		const char *upstream_refname;
>        +		int opt_out;
>         >       - 		if (!skip_prefix(full_name, "refs/heads/", &short_name))
>       - 			BUG("filter returned non-branch ref '%s'", full_name);
>       + 		if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
>       + 			BUG("filter returned non-branch ref '%s'", branch_refname);
>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
>         					FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
>         			continue;
>         >        +		strbuf_reset(&key);
>       -+		strbuf_addf(&key, "branch.%s.deletemerged", short_name);
>       ++		strbuf_addf(&key, "branch.%s.deletemerged", branch_name);
>        +		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
>        +		    !opt_out) {
>        +			if (!(flags & DELETE_BRANCH_QUIET))
>        +				fprintf(stderr,
>        +					_("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
>       -+					short_name, short_name);
>       ++					branch_name, branch_name);
>        +			continue;
>        +		}
>        +
>       - 		strset_add(&deletable, short_name);
>       + 		strset_add(&deletable_branch_names, branch_name);
>         	}
>         >        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
>       @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea
>         				      flags);
>         >        +	strbuf_release(&key);
>       - 	strvec_clear(&to_delete);
>       - 	strset_clear(&deletable);
>       + 	strvec_clear(&branches_to_delete);
>       + 	strset_clear(&deletable_branch_names);
>         	ref_array_clear(&candidates);
>        >         ## t/t3200-branch.sh ##
>   7:  7aa9d5db14 ! 7:  13bac431a3 branch: add --dry-run for --delete-merged
>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
>         >         DESCRIPTION
>         -----------
>       -@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top
>       - of it. If that kept branch in turn tracks a branch that is being
>       - deleted, its now-stale upstream configuration is cleared.
>       +@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local
>       + upstreams is kept, so a branch is never deleted out from under stacked
>       + work.
>         >        +`--dry-run`::
>        +	With `--delete-merged`, print which branches would be
>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
>         			char *refname = name + branch_name_pos;
>         			if (!(flags & DELETE_BRANCH_QUIET))
>         				printf(remote_branch
>       -@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)
>       -  * base is itself merged, so when its own upstream is also going away
>       -  * (no surviving branch tracks it), clear the base's now-stale upstream.
>       -  */
>       --static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
>       -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,
>       -+				unsigned int flags)
>       - {
>       - 	struct strset spared = STRSET_INIT;
>       - 	struct spare_data data = { .deletable = deletable, .spared = &spared };
>       -@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable
>       -
>       - 	refs_for_each_branch_ref(refs, spare_stacked_base, &data);
>       -
>       --	strset_for_each_entry(&spared, &iter, entry) {
>       --		struct branch *branch = branch_get(entry->key);
>       --		const char *upstream = branch_get_upstream(branch, NULL);
>       --		const char *up_short;
>       -+	if (!(flags & DELETE_BRANCH_DRY_RUN)) {
>       -+		strset_for_each_entry(&spared, &iter, entry) {
>       -+			struct branch *branch = branch_get(entry->key);
>       -+			const char *upstream = branch_get_upstream(branch, NULL);
>       -+			const char *up_short;
>       -
>       --		if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       --		    !strset_contains(deletable, up_short))
>       --			continue;
>       -+			if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) ||
>       -+			    !strset_contains(deletable, up_short))
>       -+				continue;
>       -
>       --		strbuf_reset(&key);
>       --		strbuf_addf(&key, "branch.%s.merge", branch->name);
>       --		repo_config_set_gently(the_repository, key.buf, NULL);
>       --		strbuf_reset(&key);
>       --		strbuf_addf(&key, "branch.%s.remote", branch->name);
>       --		repo_config_set_gently(the_repository, key.buf, NULL);
>       -+			strbuf_reset(&key);
>       -+			strbuf_addf(&key, "branch.%s.merge", branch->name);
>       -+			repo_config_set_gently(the_repository, key.buf, NULL);
>       -+			strbuf_reset(&key);
>       -+			strbuf_addf(&key, "branch.%s.remote", branch->name);
>       -+			repo_config_set_gently(the_repository, key.buf, NULL);
>       -+		}
>       - 	}
>       -
>       - 	strbuf_release(&key);
>       -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,
>       - 		strset_add(&deletable, short_name);
>       - 	}
>       -
>       --	spare_stacked_bases(refs, &deletable);
>       -+	spare_stacked_bases(refs, &deletable, flags);
>       -
>       - 	strset_for_each_entry(&deletable, &iter, entry)
>       - 		strvec_push(&to_delete, entry->key);
>        @@ builtin/branch.c: int cmd_branch(int argc,
>         	int delete = 0, rename = 0, copy = 0, list = 0,
>         	    unset_upstream = 0, show_current = 0, edit_description = 0;
>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a
>         		check_branches <<-\EOF &&
>         		feature
>         		main
>       -@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra
>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra
>         		git checkout -b tip mid --track &&
>         		git commit --allow-empty -m "tip work" &&
>         >        +		git branch --dry-run --delete-merged origin/next \
>       -+			--delete-merged lower &&
>       ++			--delete-merged lower >actual 2>&1 &&
>       ++		test_must_be_empty actual &&
>        +
>       -+		git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
>       ++		git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
>        +		cat >expect <<-\EOF &&
>       ++		branch.lower.remote origin
>       ++		branch.lower.merge refs/heads/next
>        +		branch.mid.remote .
>        +		branch.mid.merge refs/heads/lower
>        +		branch.tip.remote .
>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr
>        +		test_cmp expect actual &&
>        +
>         		git branch --delete-merged origin/next \
>       - 			--delete-merged lower &&
>       -
>       + 			--delete-merged lower >actual 2>&1 &&
>       + 		test_must_be_empty actual &&
>        @@ t/t3200-branch.sh: test_expect_success "branch -d still deletes a deleteMerged=false branch" '
>         	)
>         '
> 

@gitgitgadget-git

Copy link
Copy Markdown

Harald Nordgren wrote on the Git mailing list (how to reply to this email):

On Wed, Jul 22, 2026 at 3:39 PM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Harald
>
> On 22/07/2026 08:10, Harald Nordgren via GitGitGadget wrote:
> > Delete branches that have already been merged on upstream.
> >
> > Changes in v20:
> >
> >   * Protect branches transitively required by a surviving local upstream
> >     stack. Traverse upstream chains once and defer delete-set mutation until
> >     traversal completes.
> >   * Make stacked-branch handling independent of ref iteration order and
> >     update the documentation accordingly.
> >   * Clarify variable names with regards to branch names (short) to reduce
> >     confusion.
>
> I'm having a hard time reading the range diff due to the renaming of the
> members of "struct stacked_branch_data". Can you explain what has
> changed in the logic to protect branches that are upstreams of unmerged
> branches and why please? In particular why wasn't sufficient to stop
> removing the members of "spared" from "deletable" in
> spare_stacked_base() and remove them after refs_for_each_branch_ref()
> has returned instead.

Hmm. The main idea is to bite the bullet and actually traverse the
graph since it didn't seem possible to finish the job correctly in a
single pass anotherwise which I'm trying to demonstrate with

    test_expect_success '--delete-merged keeps the upstream chain of a
surviving branch' '

It becomes a bit like whack-a-mole when Junio asks me to clarify the
code and now you cannot review it because of that. I'm still not
friends with range-diff even after using it for some time, I agree
that the diff v19...v20 doesn't look very inviting.


Harald

@gitgitgadget-git

Copy link
Copy Markdown

This patch series is no longer integrated into seen.

@gitgitgadget-git

Copy link
Copy Markdown

This patch series was integrated into seen via 9a1e83c.

@gitgitgadget-git

Copy link
Copy Markdown

There was a status update in the "Cooking" section about the branch hn/branch-delete-merged on the Git mailing list:

The 'git branch' command has been taught the '--delete-merged' option
to remove local branches that are already merged into their tracked
remote-tracking branches.

Needs review.
cf. <9b9b9a2c-dd0f-44f8-b80e-565eed9a55a8@gmail.com>
cf. <xmqqik6an5t3.fsf@gitster.g>
source: <pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants