branch: delete-merged#2285
Conversation
7bb8db6 to
3fce72f
Compare
4d3e620 to
94014b8
Compare
184a37a to
14e3085
Compare
|
Submitted as pull.2285.git.git.1777671337839.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
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". |
dd4da62 to
66dac97
Compare
|
/submit |
|
Submitted as pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
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> | |||
There was a problem hiding this comment.
"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]|
User |
66dac97 to
6462642
Compare
| @@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> | |||
| git branch (-c|-C) [<old-branch>] <new-branch> | |||
There was a problem hiding this comment.
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>] | |||
There was a problem hiding this comment.
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| @@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name, | |||
| * upstream, if any, otherwise with HEAD", we should just | |||
There was a problem hiding this comment.
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|
/submit |
|
Submitted as pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
There was a status update in the "Cooking" section about the branch "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> | |||
There was a problem hiding this comment.
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_doneThere was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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> | |||
There was a problem hiding this comment.
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_doneThere was a problem hiding this comment.
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|
/submit |
|
Submitted as pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series is no longer integrated into seen. |
|
This branch is now known as |
|
This patch series was integrated into seen via 8b3bfdd. |
|
/submit |
|
Submitted as pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series is no longer integrated into seen. |
|
This patch series was integrated into seen via a224025. |
chiara5555
left a comment
There was a problem hiding this comment.
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>
|
/submit |
|
Submitted as pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series is no longer integrated into seen. |

Delete branches that have already been merged on upstream.
Changes in v18:
Changes in v17:
--delete-mergedno longer deletes a branch out from under one stacked on top of it.--dry-runandbranch.<name>.deleteMergedopt-out fully into their own commits.Changes in v16:
delete_merged_branches()to take anunsigned int flagsargument instead of separatequiet/dry_runbooleans, matchingdelete_branches()strbufacross the skip-config loop (strbuf_resetper iteration, singlestrbuf_releaseafter) instead of allocating and freeing it each time--delete-mergedtests 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 viatest_cmpreposet up by asetup_repo_for_delete_mergedhelper, and rename helpers off the oldpm_/prunenaming( cd ... )subshells instead of prefixing every command with-CChanges in v15:
final, but something to advance the discussion.
warning.
stays deferred.
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
--forked coverage), renamed the misleading trunk
fixture, and replaced the misnamed detached branch with git checkout
--detach.
Changes in v14:
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.
the derived locals can't disagree.
branches too, hopefully not related
(branch: delete-merged #2285).
Changes in v13:
instead of a post-pass, so non-matching branches are never allocated.
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
delete_branches()/check_branch_commit() with a single unsigned int flags.
own branch walk.
(e.g. git branch --prune-merged origin/main 'feature*') instead of
repeating the option.
Changes in v12:
options.
a ref or a glob.
Changes in v11:
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.
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.
Changes in v10:
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
Changes in v9:
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
Changes in v8:
upstream
Changes in v7:
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:
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
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.
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
Changes in v4:
protected_default_refs set in collect_forked_set.
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).
ref, so a topic branch configured to push to origin/main is never pruned.
(not by upstream alone); spare a branch whose push ref is the remote
default.
Changes in v3:
Changes in v2:
--prune-merged now just calls git branch --prune-merged after fetching.
options are gone, replaced by per-branch opt-out via
branch..pruneMerged.
lives on the given remote (read-only building block).
if their tip is reachable from the upstream tracking ref; --force skips
that safety check.
every configured remote at once.
long-running topic branch) even with --force; doesn't affect explicit git
branch -d.
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
--prune-merged.
shrunk since most logic moved.
cc: "Kristoffer Haugsbakk" kristofferhaugsbakk@fastmail.com
cc: Johannes Sixt j6t@kdbg.org
cc: Phillip Wood phillip.wood123@gmail.com