branch: delete-merged by HaraldNordgren · Pull Request #2285 · git/git · GitHub
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 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

@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

@@ -24,6 +24,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.

"Kristoffer Haugsbakk" wrote on the Git mailing list (how to reply to this email):

On Mon, May 4, 2026, at 20:27, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> List local branches whose configured upstream falls within any of
> the given <remote> arguments. <remote> may be either a configured
> remote name (matching all of its remote-tracking refs) or a single
> remote-tracking ref. Multiple <remote> arguments are unioned.
>
> This is the building block for --prune-merged, which deletes the
> listed branches.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>

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

Here and below and on the other patches.

> ---
>  Documentation/git-branch.adoc |  12 ++++
>  builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-
>  t/t3200-branch.sh             |  54 +++++++++++++++++
>  3 files changed, 174 insertions(+), 2 deletions(-)
>[snip]

@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
@@ -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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > 	git branch --prune-merged <branch>...

Please see my comments on the previous version about the naming of this option. I really think we need to start a discussion to find a better name for this option as the other options to delete a branch are named "delete" rather than "prune" and this does not remove the branches listed by "--merge"

> deletes the local branches that "--forked <branch>" would list,
> keeping only those whose tip is reachable from their configured
> upstream: the work has already landed on the upstream they track,
> so the local copy is no longer needed.
> > Reachability is read from local refs; nothing is fetched. Run
> "git fetch" first if you want fresh upstream refs.

I don't  think this sentence adds anything - git never fetches unless the user explicitly asks it to.

> > Three kinds of branches are spared:
> >    * any branch checked out in any worktree;
>    * any branch whose upstream no longer resolves locally, 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 left
>      alone. Only branches that push somewhere other than their
>      upstream, typically topics in a fork workflow, are candidates.
> > Branches that are not yet merged into their upstream are reported
> as a short warning and skipped, so one unmerged topic does not
> abort the whole sweep.

I'm not sure about this warning - the user has asked us to delete the branches whose upstreams match those passed on the commandline and that have been merged so do they really want to hear about the ones that have not been merged? It might be useful to have a way to list those that have not been merged in the future.

> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
>   Documentation/git-branch.adoc |  24 ++++
>   builtin/branch.c              |  67 +++++++++++-
>   t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++
>   3 files changed, 290 insertions(+), 2 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 62ebab6051..fdaccc9662 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
>   git branch (-c|-C) [<old-branch>] <new-branch>
>   git branch (-d|-D) [-r] <branch-name>...
>   git branch --edit-description [<branch-name>]
> +git branch --prune-merged <branch>...
>   >   DESCRIPTION
>   -----------
> @@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
>   	Print the name of the current branch. In detached `HEAD` state,
>   	nothing is printed.
>   > +`--prune-merged <branch>...`::
> +	Delete the local branches that `--forked` would list for the
> +	given _<branch>_ arguments, but only those whose tip is
> +	reachable from their configured upstream. In other words, the
> +	work on the branch has already landed on the upstream it
> +	tracks, so the local copy is no longer needed. Several
> +	_<branch>_ patterns may be given, e.g. `git branch
> +	--prune-merged origin/main 'feature*'`.
> ++
> +Reachability is checked against whatever the upstream refs say
> +locally; nothing is fetched. Run `git fetch` first if you want
> +the upstream refs refreshed.
Maybe

Reachability is checked against the remote-tracking branch. Run `git fetch` first if you want update the remote-tracking branch.

> ++
> +A branch is left alone if any of the following holds:

s/left alone/not deleted/

> +its upstream no longer resolves locally; it is checked out in any

s/upstream no longer resolves locally/upstream remote-tracking branch no longer exists/

> +worktree; or its push destination (`<branch>@{push}`) equals its
> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +from a freshly pulled trunk that just looks "fully merged".

What's a "freshly pulled trunk"? "trunk" does not appear in gitglossary(7)

> ++
> +Branches refused by the "fully merged" safety check are listed as
> +warnings and skipped; pass them to `git branch -D` explicitly if
> +you want them gone.

s/them gone/to delete them/

> +
>   `-v`::
>   `-vv`::
>   `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 2cc5a8cde0..af37a0ceb7 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
>   	N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
>   	N_("git branch [<options>] [-r | -a] [--points-at]"),
>   	N_("git branch [<options>] [-r | -a] [--format]"),
> +	N_("git branch [<options>] --prune-merged <branch>..."),
>   	NULL
>   };
>   > @@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
>   	return 0;
>   }
>   > +static int prune_merged_branches(int argc, const char **argv,
> +				 int quiet)
> +{
> +	struct ref_store *refs = get_main_ref_store(the_repository);
> +	struct ref_filter filter = REF_FILTER_INIT;
> +	struct ref_array candidates;
> +	struct strvec deletable = STRVEC_INIT;
> +	int i, ret = 0;
> +
> +	if (!argc)
> +		die(_("--prune-merged requires at least one <branch>"));
> +
> +	for (i = 0; i < argc; i++)
> +		if (ref_filter_forked_add(&filter, argv[i]) < 0)
> +			die(_("'%s' is not a valid branch or pattern"), argv[i]);
> +
> +	filter.kind = FILTER_REFS_BRANCHES;
> +	memset(&candidates, 0, sizeof(candidates));

It would be nicer to add "= { 0 }" to the declaration of candidates above.

> +	filter_refs(&candidates, &filter, filter.kind);
> +
> +	for (i = 0; i < candidates.nr; i++) {
> +		const char *full_name = candidates.items[i]->refname;
> +		const char *short_name;
> +		struct branch *branch;
> +		const char *upstream, *push;
> +
> +		if (!skip_prefix(full_name, "refs/heads/", &short_name))
> +			continue;

If we've set filter.kind = FILTER_REFS_BRANCHS how can this condition fail?

> +		if (branch_checked_out(full_name))
> +			continue;
> +
> +		branch = branch_get(short_name);
> +		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;

How can branch be NULL? Don't we require branch_get() to succeed in order to filter it?

> +		if (!upstream || !refs_ref_exists(refs, upstream))
> +			continue;
> +		push = branch ? branch_get_push(branch, NULL) : NULL;
> +		if (!push || !strcmp(push, upstream))
> +			continue;

By the time we've reached this point we know that branch@{upstream}exists and does not match branch@{push} - good

> +		strvec_push(&deletable, short_name);
> +	}
> +
> +	if (deletable.nr)
> +		ret = delete_branches(deletable.nr, deletable.v,
> +				      FILTER_REFS_BRANCHES,
> +				      DELETE_BRANCH_WARN_ONLY |
> +				      DELETE_BRANCH_NO_HEAD_FALLBACK |
> +				      (quiet ? DELETE_BRANCH_QUIET : 0));

Here we delete the branches - good.
> +		OPT_BOOL(0, "prune-merged", &prune_merged,
> +			N_("delete local branches whose upstream matches <branch> and is merged")),

s/is/are/

Sorry I didn't get round to reviewing these last week, I'll try and take a look at the tests and the other patches tomorrow

Thanks

Phillip

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..27ea1319bb 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
>   	test_grep "requires a value" err
>   '
>   > +test_expect_success '--prune-merged: setup' '
> +	test_create_repo pm-upstream &&
> +	test_commit -C pm-upstream base &&
> +	git -C pm-upstream checkout -b next &&
> +	test_commit -C pm-upstream one-commit &&
> +	test_commit -C pm-upstream two-commit &&
> +	git -C pm-upstream branch one HEAD~ &&
> +	git -C pm-upstream branch two HEAD &&
> +	git -C pm-upstream branch wip main &&
> +	git -C pm-upstream checkout main &&
> +	test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> +	test_when_finished "rm -rf pm-merged" &&
> +	git clone pm-upstream pm-merged &&
> +	git -C pm-merged remote add fork ../pm-fork &&
> +	test_config -C pm-merged remote.pushDefault fork &&
> +	test_config -C pm-merged push.default current &&
> +	git -C pm-merged branch one one-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next one &&
> +	git -C pm-merged branch two two-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next two &&
> +
> +	git -C pm-merged branch --prune-merged "origin/*" &&
> +
> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a literal upstream' '
> +	test_when_finished "rm -rf pm-literal" &&
> +	git clone pm-upstream pm-literal &&
> +	git -C pm-literal remote add fork ../pm-fork &&
> +	test_config -C pm-literal remote.pushDefault fork &&
> +	test_config -C pm-literal push.default current &&
> +	git -C pm-literal branch one one-commit &&
> +	git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-literal branch --prune-merged origin/next &&
> +
> +	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> +	test_when_finished "rm -rf pm-union" &&
> +	git clone pm-upstream pm-union &&
> +	git -C pm-union remote add fork ../pm-fork &&
> +	test_config -C pm-union remote.pushDefault fork &&
> +	test_config -C pm-union push.default current &&
> +	git -C pm-union branch one one-commit &&
> +	git -C pm-union branch --set-upstream-to=origin/next one &&
> +	git -C pm-union branch two base &&
> +	git -C pm-union branch --set-upstream-to=origin/main two &&
> +	git -C pm-union checkout --detach &&
> +
> +	git -C pm-union branch --prune-merged origin/next origin/main &&
> +
> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> +	test_when_finished "rm -rf pm-local" &&
> +	git clone pm-upstream pm-local &&
> +	git -C pm-local remote add fork ../pm-fork &&
> +	test_config -C pm-local remote.pushDefault fork &&
> +	test_config -C pm-local push.default current &&
> +	git -C pm-local checkout -b trunk &&
> +	git -C pm-local branch one one-commit &&
> +	git -C pm-local branch --set-upstream-to=trunk one &&
> +	git -C pm-local merge --ff-only one-commit &&
> +
> +	git -C pm-local branch --prune-merged trunk &&
> +
> +	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> +	test_when_finished "rm -rf pm-unmerged" &&
> +	git clone pm-upstream pm-unmerged &&
> +	git -C pm-unmerged remote add fork ../pm-fork &&
> +	test_config -C pm-unmerged remote.pushDefault fork &&
> +	test_config -C pm-unmerged push.default current &&
> +	git -C pm-unmerged checkout -b wip origin/wip &&
> +	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> +	test_commit -C pm-unmerged local-only &&
> +	git -C pm-unmerged checkout - &&
> +
> +	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> +	test_grep "not fully merged" err &&
> +	test_grep ! "If you are sure you want to delete it" err &&
> +	git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> +	test_when_finished "rm -rf pm-nohead" &&
> +	git clone pm-upstream pm-nohead &&
> +	git -C pm-nohead remote add fork ../pm-fork &&
> +	test_config -C pm-nohead remote.pushDefault fork &&
> +	test_config -C pm-nohead push.default current &&
> +	git -C pm-nohead branch topic one-commit &&
> +	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> +	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> +	test_grep ! "not yet merged to HEAD" err &&
> +	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> +	test_when_finished "rm -rf pm-upstream-gone" &&
> +	git clone pm-upstream pm-upstream-gone &&
> +	git -C pm-upstream-gone remote add fork ../pm-fork &&
> +	test_config -C pm-upstream-gone remote.pushDefault fork &&
> +	test_config -C pm-upstream-gone push.default current &&
> +	git -C pm-upstream-gone branch one one-commit &&
> +	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> +	git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> +	git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> +	test_when_finished "rm -rf pm-head" &&
> +	git clone pm-upstream pm-head &&
> +	git -C pm-head remote add fork ../pm-fork &&
> +	test_config -C pm-head remote.pushDefault fork &&
> +	test_config -C pm-head push.default current &&
> +	git -C pm-head checkout -b one one-commit &&
> +	git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-head branch --prune-merged "origin/*" &&
> +
> +	git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> +	test_when_finished "rm -rf pm-push-eq" &&
> +	git clone pm-upstream pm-push-eq &&
> +	git -C pm-push-eq checkout --detach &&
> +
> +	git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> +	test_when_finished "rm -rf pm-push-branch" &&
> +	git clone pm-upstream pm-push-branch &&
> +	git -C pm-push-branch remote add fork ../pm-fork &&
> +	test_config -C pm-push-branch remote.pushDefault fork &&
> +	test_config -C pm-push-branch push.default current &&
> +	test_config -C pm-push-branch branch.main.pushRemote origin &&
> +	git -C pm-push-branch checkout --detach &&
> +
> +	git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> +	test_when_finished "rm -rf pm-push-diff" &&
> +	git clone pm-upstream pm-push-diff &&
> +	git -C pm-push-diff remote add fork ../pm-fork &&
> +	test_config -C pm-push-diff remote.pushDefault fork &&
> +	test_config -C pm-push-diff push.default current &&
> +	git -C pm-push-diff branch topic one-commit &&
> +	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> +	git -C pm-push-diff checkout --detach &&
> +
> +	git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> +	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> +	test_must_fail git -C forked branch --prune-merged 2>err &&
> +	test_grep "requires at least one <branch>" err
> +'
> +
> +test_expect_success '--prune-merged takes positional <branch> arguments' '
> +	test_when_finished "rm -rf pm-positional" &&
> +	git clone pm-upstream pm-positional &&
> +	git -C pm-positional remote add fork ../pm-fork &&
> +	test_config -C pm-positional remote.pushDefault fork &&
> +	test_config -C pm-positional push.default current &&
> +	git -C pm-positional branch one one-commit &&
> +	git -C pm-positional branch --set-upstream-to=origin/next one &&
> +	git -C pm-positional branch two base &&
> +	git -C pm-positional branch --set-upstream-to=origin/main two &&
> +	git -C pm-positional checkout --detach &&
> +
> +	git -C pm-positional branch --prune-merged origin/next origin/main &&
> +
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> +'
> +
>   test_done

@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]

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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@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") 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 --prune-merged, which deletes the
> listed branches once they have landed on their upstream.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
>   Documentation/git-branch.adoc | 10 +++-
>   builtin/branch.c              | 18 ++++++-
>   ref-filter.c                  | 70 ++++++++++++++++++++++++++
>   ref-filter.h                  | 10 ++++
>   t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++
>   5 files changed, 197 insertions(+), 3 deletions(-)

It's nice to see that moving the code into the ref-filter.c has reduced the overall number of additions by ~50 lines. The documentation and implementation look fine though I have a couple of thoughts:

 - Previous iterations supported "origin" as a short hand for the branch
   origin/HEAD points to. That was nice because it means we can use the
   same syntax for "git checkout -b" and "git branch --forked". It
   would probably be a good idea to support it.

 - We could probably be a bit smarter about the way we handle patterns
   by copying what dwim_ref() does to support things like
   remotes/origin/* but I don't think we need to do that now.

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index e7829c2c4b..4e7deddc04 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
>   	test_cmp expect actual
>   '
>   > +test_expect_success '--forked: setup' '
> +	test_create_repo forked-upstream &&
> +	test_commit -C forked-upstream base &&
> +	git -C forked-upstream branch one base &&
> +	git -C forked-upstream branch two base &&
> +
> +	test_create_repo forked-other &&
> +	test_commit -C forked-other other-base &&
> +	git -C forked-other branch foreign other-base &&
> +
> +	git clone forked-upstream forked &&
> +	git -C forked remote add other ../forked-other &&

We can use "add -f" to fetch here rather than doing it separately.

> +	git -C forked fetch other &&
> +	git -C forked branch local-base &&
> +	git -C forked branch --track local-one origin/one &&
> +	git -C forked branch --track local-two origin/two &&
> +	git -C forked branch --track local-foreign other/foreign &&
> +	git -C forked branch detached &&

Normally we use "detached" to mean no branch, lets read on and see how this is used ...

> +	git -C forked branch --track local-trunk local-base
> +'
> +
> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
> +	git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&

origin/one and origin/two point to the same commit, so this demonstrates that we're checking the branch names, not the topology which is good. All of the local branches point at their upstream which isn't very realistic - I wonder if we should add some local commits?

The tests all look sensible, but there is no coverage for combining --forked with branch names as in

    git branch --forked <arg> <branch>

Thanks

Phillip


> +	echo local-one >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <glob> filters by wildmatch' '
> +	git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
> +	cat >expect <<-\EOF &&
> +	local-one
> +	local-two
> +	main
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <local-branch> matches branches with local upstream' '
> +	git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
> +	echo local-trunk >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked can be repeated to widen the filter' '
> +	git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
> +	cat >expect <<-\EOF &&
> +	local-foreign
> +	local-one
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> +	git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
> +	cat >expect <<-\EOF &&
> +	local-foreign
> +	local-trunk
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
> +	git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
> +	cat >expect <<-\EOF &&
> +	local-foreign
> +	local-one
> +	local-two
> +	main
> +	EOF
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked composes with --no-merged' '
> +	test_when_finished "git -C forked checkout detached" &&
> +	git -C forked checkout local-one &&
> +	test_commit -C forked local-only &&
> +	git -C forked branch --forked "origin/*" --no-merged origin/one \
> +		--format="%(refname:short)" >actual &&
> +	echo local-one >expect &&
> +	test_cmp expect actual
> +'
> +
> +test_expect_success '--forked rejects unknown branch/pattern' '
> +	test_must_fail git -C forked branch --forked nope 2>err &&
> +	test_grep "not a valid branch or pattern" err
> +'
> +
> +test_expect_success '--forked requires a value' '
> +	test_must_fail git -C forked branch --forked 2>err &&
> +	test_grep "requires a value" err
> +'
> +
>   test_done

Comment thread builtin/branch.c
@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,
* upstream, if any, otherwise with HEAD", we should just

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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
>
> @@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
>   	int i;
>   	int ret = 0;
>   	int remote_branch = 0;
> -	int force, quiet;
> +	int force, quiet, dry_run, no_head_fallback;

As with the previous patch it would be safer to initialize the new variables where they are declared.

>   	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)
> +				printf(remote_branch
> +					? _("Would delete remote-tracking branch %s (was %s).\n")
> +					: _("Would delete branch %s (was %s).\n"),

I wondered what the "was %s" was about but it prints the symref target or oid of the ref.

Thanks

Phillip

> +					name + branch_name_pos, describe_ref);
> +		} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
>   			char *refname = name + branch_name_pos;
>   			if (!quiet)
>   				printf(remote_branch

@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v15.git.git.1781542042.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-v15

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

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

@gitgitgadget-git

Copy link
Copy Markdown

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

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

Needs review.
source: <pull.2285.v15.git.git.1781542042.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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > With --dry-run, --prune-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.

I can see this being very useful.

> diff --git a/builtin/branch.c b/builtin/branch.c
> index 52a0371292..7c52a88af2 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
>   }
>   >   static int prune_merged_branches(int argc, const char **argv,
> -				 int quiet)
> +				 int quiet, int dry_run)

Let's not start adding multiple boolean augments - use a flags argument like we do for delete_branches() - if you get feedback on one patch you should think about whether it applies later in the series as well. The rest of the implementation looks good.

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 3f7b1fc3d6..305c0141fc 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
>   	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
>   '
>   > +test_expect_success '--prune-merged --dry-run lists but does not delete' '

A good way to test --dry-run would be to add it to an existing test before calling --prune-merged without --dry-run.

Thanks

Phillip

> +	test_when_finished "rm -rf pm-dry" &&
> +	git clone pm-upstream pm-dry &&
> +	git -C pm-dry remote add fork ../pm-fork &&
> +	test_config -C pm-dry remote.pushDefault fork &&
> +	test_config -C pm-dry push.default current &&
> +	git -C pm-dry branch one one-commit &&
> +	git -C pm-dry branch --set-upstream-to=origin/next one &&
> +	git -C pm-dry branch two two-commit &&
> +	git -C pm-dry branch --set-upstream-to=origin/next two &&
> +
> +	git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
> +	test_grep "Would delete branch one " actual &&
> +	test_grep "Would delete branch two " actual &&
> +
> +	git -C pm-dry rev-parse --verify refs/heads/one &&
> +	git -C pm-dry rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
> +	test_when_finished "rm -rf pm-dry-mixed" &&
> +	git clone pm-upstream pm-dry-mixed &&
> +	git -C pm-dry-mixed remote add fork ../pm-fork &&
> +	test_config -C pm-dry-mixed remote.pushDefault fork &&
> +	test_config -C pm-dry-mixed push.default current &&
> +	git -C pm-dry-mixed checkout -b wip origin/next &&
> +	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
> +	test_commit -C pm-dry-mixed local-only &&
> +	git -C pm-dry-mixed checkout - &&
> +	git -C pm-dry-mixed branch merged one-commit &&
> +	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
> +
> +	git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
> +	test_grep "Would delete branch merged" out &&
> +	test_grep ! "Would delete branch wip" out &&
> +	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
> +	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
> +'
> +
> +test_expect_success '--dry-run without --prune-merged is rejected' '
> +	test_must_fail git -C forked branch --dry-run 2>err &&
> +	test_grep "requires --prune-merged" err
> +'
> +
>   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):

> > With --dry-run, --prune-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.
>
> I can see this being very useful.

Great to hear and thanks for taking the time to review this! Much appreciated!

> >   static int prune_merged_branches(int argc, const char **argv,
> > -                              int quiet)
> > +                              int quiet, int dry_run)
>
> Let's not start adding multiple boolean augments - use a flags argument
> like we do for delete_branches() - if you get feedback on one patch you
> should think about whether it applies later in the series as well. The
> rest of the implementation looks good.

I'm trying to generalize all feedback, but sometimes I miss things.
Thanks for pointing it out!


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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Setting branch.<name>.pruneMerged=false exempts that branch from
> "git branch --prune-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.

Sounds good

> @@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
>   		if (!push || !strcmp(push, upstream))
>   			continue;
>   > +		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
> +		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
> +		    !opt_out) {
> +			if (!quiet)
> +				fprintf(stderr,
> +					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
> +					short_name, short_name);
> +			strbuf_release(&key);
> +			continue;
> +		}
> +		strbuf_release(&key);

As this is in a loop we don't want to free the buffer on each iteration, only at the end. You should call strbuf_reset() just before strbuf_addf() above and then move this call to strbuf_release() out of the loop.

> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> +	test_when_finished "rm -rf pm-optout" &&
> +	git clone pm-upstream pm-optout &&
> +	git -C pm-optout remote add fork ../pm-fork &&
> +	test_config -C pm-optout remote.pushDefault fork &&
> +	test_config -C pm-optout push.default current &&
> +	git -C pm-optout branch one one-commit &&
> +	git -C pm-optout branch --set-upstream-to=origin/next one &&
> +	git -C pm-optout branch two two-commit &&
> +	git -C pm-optout branch --set-upstream-to=origin/next two &&
> +	test_config -C pm-optout branch.one.pruneMerged false &&
> +
> +	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> +
> +	git -C pm-optout rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
> +	test_grep "Skipping .one." err

Do we really need a whole new setup to test this - can't we just add a protected branch to an existing test?

Thanks

Phillip

> +
> +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
> +	test_when_finished "rm -rf pm-optout-d" &&
> +	git clone pm-upstream pm-optout-d &&
> +	git -C pm-optout-d branch one one-commit &&
> +	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
> +	test_config -C pm-optout-d branch.one.pruneMerged false &&
> +
> +	git -C pm-optout-d branch -d one &&
> +	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> +'
> +
>   test_done

@@ -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 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:

Carrying on where I left off yesterday ...

> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..27ea1319bb 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
>   	test_grep "requires a value" err
>   '
>   > +test_expect_success '--prune-merged: setup' '
> +	test_create_repo pm-upstream &&

The rest of this test would be easier to read if we did

	(
		cd pm-upstream &&
		...
	)

rather than prefixing every command with "-C pm-upstream"

> +	test_commit -C pm-upstream base &&
> +	git -C pm-upstream checkout -b next &&
> +	test_commit -C pm-upstream one-commit &&
> +	test_commit -C pm-upstream two-commit &&
> +	git -C pm-upstream branch one HEAD~ &&
> +	git -C pm-upstream branch two HEAD &&
> +	git -C pm-upstream branch wip main &&
> +	git -C pm-upstream checkout main &&
> +	test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> +	test_when_finished "rm -rf pm-merged" &&
> +	git clone pm-upstream pm-merged &&
> +	git -C pm-merged remote add fork ../pm-fork &&
> +	test_config -C pm-merged remote.pushDefault fork &&
> +	test_config -C pm-merged push.default current &&

So we clone upstream and add fork as the default push remote. I find the pm- prefixes rather distracting. It would be clearer to me if we just called the repositories "upstream", "fork" and "repo"

> +	git -C pm-merged branch one one-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next one &&
> +	git -C pm-merged branch two two-commit &&
> +	git -C pm-merged branch --set-upstream-to=origin/next two &&

Now we set up a couple of local branches with no local commits that track origin/next which seems a bit odd. Why don't we create local branches based one origin/next (and origin/main if we're using a wildcard below) with some local commits like a user would?

> +	git -C pm-merged branch --prune-merged "origin/*" &&

Here we delete all the branches whose upstream is on origin - which is all the branches that we've created so we're not really testing the safety features.

> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-merged rev-parse --verify refs/heads/two

This verifies that the branches are deleted. It would be a good idea to check the output of command above to check --prune-merged prints what we expect it to and that it leaves branches with other upstreams.

> +test_expect_success '--prune-merged accepts a literal upstream' '
> +	test_when_finished "rm -rf pm-literal" &&
> +	git clone pm-upstream pm-literal &&

let's not litter the test directory with a hundred repositories - just call it "repo" and add remove it with test_when_finished in each test, or reuse it so we don't waste time cloning and setting up the config each time (that would mean not using test_config).

> +	git -C pm-literal remote add fork ../pm-fork &&
> +	test_config -C pm-literal remote.pushDefault fork &&
> +	test_config -C pm-literal push.default current &&
> +	git -C pm-literal branch one one-commit &&
> +	git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-literal branch --prune-merged origin/next &&
> +
> +	test_must_fail git -C pm-literal rev-parse --verify refs/heads/one

Again we're not testing that nothing else is deleted.

> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> +	test_when_finished "rm -rf pm-union" &&
> +	git clone pm-upstream pm-union &&
> +	git -C pm-union remote add fork ../pm-fork &&
> +	test_config -C pm-union remote.pushDefault fork &&
> +	test_config -C pm-union push.default current &&
> +	git -C pm-union branch one one-commit &&
> +	git -C pm-union branch --set-upstream-to=origin/next one &&
> +	git -C pm-union branch two base &&
> +	git -C pm-union branch --set-upstream-to=origin/main two &&
> +	git -C pm-union checkout --detach &&
> +
> +	git -C pm-union branch --prune-merged origin/next origin/main &&

This is more interesting - we don't need the test for a single literal upstream if we're doing this. Again we need to test the safety features. As these are integration tests you can do that at the same time as testing that some branches are removed - you don't need so many separate (expensive) tests.

> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> +	test_when_finished "rm -rf pm-local" &&
> +	git clone pm-upstream pm-local &&
> +	git -C pm-local remote add fork ../pm-fork &&
> +	test_config -C pm-local remote.pushDefault fork &&
> +	test_config -C pm-local push.default current &&
> +	git -C pm-local checkout -b trunk &&
> +	git -C pm-local branch one one-commit &&
> +	git -C pm-local branch --set-upstream-to=trunk one &&
> +	git -C pm-local merge --ff-only one-commit &&
> +
> +	git -C pm-local branch --prune-merged trunk &&

Can't we test a local upstream at the same time as a remote upstream and save a test case?

> +	test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> +	test_when_finished "rm -rf pm-unmerged" &&
> +	git clone pm-upstream pm-unmerged &&
> +	git -C pm-unmerged remote add fork ../pm-fork &&
> +	test_config -C pm-unmerged remote.pushDefault fork &&
> +	test_config -C pm-unmerged push.default current &&
> +	git -C pm-unmerged checkout -b wip origin/wip &&
> +	git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> +	test_commit -C pm-unmerged local-only &&
> +	git -C pm-unmerged checkout - &&
> +
> +	git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> +	test_grep "not fully merged" err &&
> +	test_grep ! "If you are sure you want to delete it" err &&

I'm always suspicious of test_grep when we know what the output should look like - it might be better to use test_cmp. This test does not check that we also delete branches that are merged when we see one that isn't.

I'm going to stop here - the tests I've read seem to me to be too much like unit tests checking one aspect of the implementation in isolation rather than checking that the whole feature works as expected.

Thanks

Phillip

> +	git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> +	test_when_finished "rm -rf pm-nohead" &&
> +	git clone pm-upstream pm-nohead &&
> +	git -C pm-nohead remote add fork ../pm-fork &&
> +	test_config -C pm-nohead remote.pushDefault fork &&
> +	test_config -C pm-nohead push.default current &&
> +	git -C pm-nohead branch topic one-commit &&
> +	git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> +	git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> +	test_grep ! "not yet merged to HEAD" err &&
> +	test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> +	test_when_finished "rm -rf pm-upstream-gone" &&
> +	git clone pm-upstream pm-upstream-gone &&
> +	git -C pm-upstream-gone remote add fork ../pm-fork &&
> +	test_config -C pm-upstream-gone remote.pushDefault fork &&
> +	test_config -C pm-upstream-gone push.default current &&
> +	git -C pm-upstream-gone branch one one-commit &&
> +	git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> +	git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> +	git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> +	test_when_finished "rm -rf pm-head" &&
> +	git clone pm-upstream pm-head &&
> +	git -C pm-head remote add fork ../pm-fork &&
> +	test_config -C pm-head remote.pushDefault fork &&
> +	test_config -C pm-head push.default current &&
> +	git -C pm-head checkout -b one one-commit &&
> +	git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> +	git -C pm-head branch --prune-merged "origin/*" &&
> +
> +	git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> +	test_when_finished "rm -rf pm-push-eq" &&
> +	git clone pm-upstream pm-push-eq &&
> +	git -C pm-push-eq checkout --detach &&
> +
> +	git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> +	test_when_finished "rm -rf pm-push-branch" &&
> +	git clone pm-upstream pm-push-branch &&
> +	git -C pm-push-branch remote add fork ../pm-fork &&
> +	test_config -C pm-push-branch remote.pushDefault fork &&
> +	test_config -C pm-push-branch push.default current &&
> +	test_config -C pm-push-branch branch.main.pushRemote origin &&
> +	git -C pm-push-branch checkout --detach &&
> +
> +	git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> +	git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> +	test_when_finished "rm -rf pm-push-diff" &&
> +	git clone pm-upstream pm-push-diff &&
> +	git -C pm-push-diff remote add fork ../pm-fork &&
> +	test_config -C pm-push-diff remote.pushDefault fork &&
> +	test_config -C pm-push-diff push.default current &&
> +	git -C pm-push-diff branch topic one-commit &&
> +	git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> +	git -C pm-push-diff checkout --detach &&
> +
> +	git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> +	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> +	test_must_fail git -C forked branch --prune-merged 2>err &&
> +	test_grep "requires at least one <branch>" err
> +'
> +
> +test_expect_success '--prune-merged takes positional <branch> arguments' '
> +	test_when_finished "rm -rf pm-positional" &&
> +	git clone pm-upstream pm-positional &&
> +	git -C pm-positional remote add fork ../pm-fork &&
> +	test_config -C pm-positional remote.pushDefault fork &&
> +	test_config -C pm-positional push.default current &&
> +	git -C pm-positional branch one one-commit &&
> +	git -C pm-positional branch --set-upstream-to=origin/next one &&
> +	git -C pm-positional branch two base &&
> +	git -C pm-positional branch --set-upstream-to=origin/main two &&
> +	git -C pm-positional checkout --detach &&
> +
> +	git -C pm-positional branch --prune-merged origin/next origin/main &&
> +
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> +	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> +'
> +
>   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):

> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> > index 4e7deddc04..27ea1319bb 100755
> > --- a/t/t3200-branch.sh
> > +++ b/t/t3200-branch.sh
> > @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
> >       test_grep "requires a value" err
> >   '
> >
> > +test_expect_success '--prune-merged: setup' '
> > +     test_create_repo pm-upstream &&
>
> The rest of this test would be easier to read if we did
>
>         (
>                 cd pm-upstream &&
>                 ...
>         )
>
> rather than prefixing every command with "-C pm-upstream"

I feel like the discussion to nest or not to nest has come up many
times in other topics as well. I don't feel strongly about either way,
but I just want to flag that if I change it now, another reviewer
might ask me to change it back later.

Should the rules be to nest inside of setup functions (and helpers?)
but not inside the actual tests?

> > +     test_commit -C pm-upstream base &&
> > +     git -C pm-upstream checkout -b next &&
> > +     test_commit -C pm-upstream one-commit &&
> > +     test_commit -C pm-upstream two-commit &&
> > +     git -C pm-upstream branch one HEAD~ &&
> > +     git -C pm-upstream branch two HEAD &&
> > +     git -C pm-upstream branch wip main &&
> > +     git -C pm-upstream checkout main &&
> > +     test_create_repo pm-fork
> > +'
> > +
> > +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> > +     test_when_finished "rm -rf pm-merged" &&
> > +     git clone pm-upstream pm-merged &&
> > +     git -C pm-merged remote add fork ../pm-fork &&
> > +     test_config -C pm-merged remote.pushDefault fork &&
> > +     test_config -C pm-merged push.default current &&
>
> So we clone upstream and add fork as the default push remote. I find the
> pm- prefixes rather distracting. It would be clearer to me if we just
> called the repositories "upstream", "fork" and "repo"

Good point.

> > +     test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> > +'
> > +
> > +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> > +     test_when_finished "rm -rf pm-unmerged" &&
> > +     git clone pm-upstream pm-unmerged &&
> > +     git -C pm-unmerged remote add fork ../pm-fork &&
> > +     test_config -C pm-unmerged remote.pushDefault fork &&
> > +     test_config -C pm-unmerged push.default current &&
> > +     git -C pm-unmerged checkout -b wip origin/wip &&
> > +     git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> > +     test_commit -C pm-unmerged local-only &&
> > +     git -C pm-unmerged checkout - &&
> > +
> > +     git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> > +     test_grep "not fully merged" err &&
> > +     test_grep ! "If you are sure you want to delete it" err &&
>
> I'm always suspicious of test_grep when we know what the output should
> look like - it might be better to use test_cmp. This test does not check
> that we also delete branches that are merged when we see one that isn't.
>
> I'm going to stop here - the tests I've read seem to me to be too much
> like unit tests checking one aspect of the implementation in isolation
> rather than checking that the whole feature works as expected.

I'll respond to the rest here: Excellent points regarding the testing
aboce, I will take a look at doing this.


Harald

@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v16.git.git.1781810729.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-v16

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

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

@gitgitgadget-git

Copy link
Copy Markdown

This patch series is no longer integrated into seen.

@gitgitgadget-git

Copy link
Copy Markdown

This branch is now known as hn/branch-delete-merged.

@gitgitgadget-git

Copy link
Copy Markdown

This patch series was integrated into seen via 8b3bfdd.

@HaraldNordgren

Copy link
Copy Markdown
Contributor Author

/submit

@gitgitgadget-git

Copy link
Copy Markdown

Submitted as pull.2285.v17.git.git.1782113388.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-v17

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

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

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

@chiara5555 chiara5555 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:

From: Harald Nordgren haraldnordgren@gmail.com

Add no_head_fallback and dry_run flags to delete_branches() so a
bulk caller (the upcoming --prune-merged) can ask strictly about
merged-into-upstream without a silent fallback to HEAD, and
rehearse deletions with the same "Would delete branch ..." wording
as the live run. Existing callers pass 0 for both and keep current
behavior.
When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
reminder otherwise compares against HEAD. For the bulk caller
every candidate is known to have an upstream, so HEAD is
irrelevant. Guard the block on head_rev so the NULL case skips
it instead of treating "NULL != reference_rev" as "diverges from
HEAD" and emitting a spurious warning.

Same comment as the last patch - use a flags argument rather than lots of individual booleans that make the call sites hard to read.

Thanks

Phillip

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() and check_branch_commit() take a pair of int
booleans (force and quiet) that the next commits would grow further.
Replace them with a single "unsigned int flags" argument and an
enum, splitting the bits back into named bool locals so the body
keeps reading the same named values.

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() two new modes for the upcoming
--delete-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
	git branch --delete-merged <branch>...

deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.

A branch is not deleted when:

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

--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.v18.git.git.1782338106.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-v18

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

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

@gitgitgadget-git

Copy link
Copy Markdown

This patch series is no longer integrated into seen.

@gitgitgadget-git

Copy link
Copy Markdown

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.

2 participants