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 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
@gitgitgadget-git

Copy link
Copy Markdown

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

Hi Harald

On 17/06/2026 20:11, Harald Nordgren wrote:
>> Right but you sent that version a few hours after I'd posted a partial
>> review which concluded by saying I'd finish it the next day. If you send
>> a new version when you are waiting for further comments it clutters the
>> list because you know you're going to have to post another revision when
>> you get the rest of the comments. Anyone reviewing the interim version
>> is wasting their time. When you receive review comments, by all means
>> start thinking about them and updating your local copy but please don't
>> post a new version until the discussion on the previous version has
>> settled down.
> > That's fair. Sorry about that.
> > Will you let me know when your review here is finished?

I've just sent a mail with another comment but that concudes this round unless you have any questions about it.

> > I received the same feedback from Junio before, so I'm not unaware of
> this problem. I am trying to slow down. I often prepare the work as
> soon as I get some comments -- I'm on paternity leave so I have a lot
> of time when the baby is sleeping -- Congratulations - I hope the baby is sleeping at night as well in the day!

> then I actively hold off on
> sending to not overload the rest of you. But at the same time I think
> it's valuable to keep up a certain pace. It's a balancing act.
It is worth waiting for the discussion to settle on each round, I'll try and be clear when I've finished looking at each revision. I'm sure other folks would appreciate you looking at their patches and commenting on them while you're waiting for feedback on yours, especially the GSoC project students.

Thanks

Phillip

@gitgitgadget-git

Copy link
Copy Markdown

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

> > I received the same feedback from Junio before, so I'm not unaware of
> > this problem. I am trying to slow down. I often prepare the work as
> > soon as I get some comments -- I'm on paternity leave so I have a lot
> > of time when the baby is sleeping --
>
> Congratulations - I hope the baby is sleeping at night as well in the day!

Thanks! It's our third, so hopefully we got the hang of it now.

He sleeps -- some of the time.

> > then I actively hold off on
> > sending to not overload the rest of you. But at the same time I think
> > it's valuable to keep up a certain pace. It's a balancing act.
> It is worth waiting for the discussion to settle on each round, I'll try
> and be clear when I've finished looking at each revision. I'm sure other
> folks would appreciate you looking at their patches and commenting on
> them while you're waiting for feedback on yours, especially the GSoC
> project students.

That's a good point!


Harald

@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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Waiting for response(s) to review comment(s).
cf. <78b6dfdd-df61-4c44-96eb-b527cb26243c@gmail.com>
cf. <f68e2a11-02a5-47b9-a01a-458eba821c37@gmail.com>
cf. <37f2a483-c8bf-4c24-84de-c6233cc20b25@gmail.com>
cf. <xmqq33yimsdp.fsf@gitster.g>
source: <pull.2285.v16.git.git.1781810729.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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Waiting for response(s) to review comment(s).
cf. <cb6fcdfb-67b4-429d-b820-c4e623f28cfa@gmail.com>
source: <pull.2285.v17.git.git.1782113388.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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Waiting for response(s) to review comment(s).
cf. <cb6fcdfb-67b4-429d-b820-c4e623f28cfa@gmail.com>
source: <pull.2285.v17.git.git.1782113388.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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Needs review.
source: <pull.2285.v18.git.git.1782338106.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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Needs review.
source: <pull.2285.v18.git.git.1782338106.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:

"git branch" command learned "--delete-merged" option to remove
local branches that have already been merged to the remote-tracking
branches they track.

Needs review.
source: <pull.2285.v18.git.git.1782338106.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 have already been merged to the remote-
tracking branches they track.

Needs review.
source: <pull.2285.v18.git.git.1782338106.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.v18.git.git.1782338106.gitgitgadget@gmail.com>

Comment thread Documentation/git-branch.adoc
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
> 

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>
@mohommedsmohommeds-source

Copy link
Copy Markdown

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>
@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

@@ -102,3 +102,10 @@ for details).
`git branch --edit-description`. Branch description is

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:
> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 047ba54778..b7595610d9 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow
>   	test_cmp expect actual
>   '
>   > +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
> +	test_when_finished "rm -rf repo" &&
> +	setup_repo_for_delete_merged &&
> +	merged_branch deleted origin/next &&
> +	merged_branch kept origin/next &&
> +	git -C repo config branch.kept.deleteMerged false &&
> +	git -C repo checkout --detach &&
> +
> +	git -C repo branch --delete-merged origin/next 2>err &&
> +
> +	test_grep "Skipping .kept." err &&
> +	test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
> +	git -C repo rev-parse --verify refs/heads/kept

As with the previous patches, I think this would be nicer if we checked the output for for-each-ref. Everything else looks fine.

Thanks

Phillip

> +'
> +
> +test_expect_success "branch -d still deletes a deleteMerged=false branch" '
> +	test_when_finished "rm -rf repo" &&
> +	setup_repo_for_delete_merged &&
> +	merged_branch kept origin/next &&
> +	git -C repo config branch.kept.deleteMerged false &&
> +	git -C repo checkout --detach &&
> +
> +	git -C repo branch -d kept &&
> +	test_must_fail git -C repo rev-parse --verify refs/heads/kept
> +'
> +
>   test_done

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):

> > +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
> > +     test_when_finished "rm -rf repo" &&
> > +     setup_repo_for_delete_merged &&
> > +     merged_branch deleted origin/next &&
> > +     merged_branch kept origin/next &&
> > +     git -C repo config branch.kept.deleteMerged false &&
> > +     git -C repo checkout --detach &&
> > +
> > +     git -C repo branch --delete-merged origin/next 2>err &&
> > +
> > +     test_grep "Skipping .kept." err &&
> > +     test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&
> > +     git -C repo rev-parse --verify refs/heads/kept
>
> As with the previous patches, I think this would be nicer if we checked
> the output for for-each-ref. Everything else looks fine.

Thanks! Yes, I have already created a helper for it, and it makes it a
lot clearer. Thanks again for a good suggestion.


Harald

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 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.

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.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>

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