Navigation Menu
-
Notifications
You must be signed in to change notification settings - Fork 28.1k
branch: delete-merged #2285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
branch: delete-merged #2285
Changes from all commits
3e29ff1
cdd4fea
a0fd5b4
a56d8fe
a84c555
d52d717
8d0323f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. Choose a reason for hiding this commentThe 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 |
||
| [--merged [<commit>]] [--no-merged [<commit>]] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Add a --forked option to "git branch" list mode that keeps only
> branches whose configured upstream matches <branch>. The argument
> can be a ref (e.g. "origin/main", "master") or a shell-style
> glob (e.g. "origin/*"). The option can be repeated to widen the
> filter.
Do we want to support a remote name as an alias for $remote/HEAD to match "git checkout -b $remote"?
> Because it is a filter on list mode, --forked composes with the
> existing list-mode filters, so
> > git branch --merged origin/main --forked 'origin/*'
> > lists branches forked from origin that have already been
> integrated into origin/main, and --no-merged inverts the question.
Nice
> 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 | 7 ++
> builtin/branch.c | 147 +++++++++++++++++++++++++++++++++-
> ref-filter.c | 10 +--
> ref-filter.h | 2 +
> t/t3200-branch.sh | 92 +++++++++++++++++++++
> 5 files changed, 249 insertions(+), 9 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index c0afddc424..8002d7f38c 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
> [--merged [<commit>]] [--no-merged [<commit>]]
> [--contains [<commit>]] [--no-contains [<commit>]]
> [--points-at <object>] [--format=<format>]
> + [(--forked <branch>)...]
Should this come before --format? I think it logically belongs with --merged and --contains which also filter the output.
> [(-r|--remotes) | (-a|--all)]
> [--list] [<pattern>...]
> git branch [--track[=(direct|inherit)] | --no-track] [-f]
> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
> > +`--forked <branch>`::
> + List only branches whose configured upstream matches
> + _<branch>_. The argument can be a ref (e.g. `origin/main`,
> + `master`) or a shell-style glob (e.g. `'origin/*'`). The
> + option can be repeated to widen the filter.
This is fine but do we want to add a sentence to the DESCRIPTION as well where it talks about "--contains" and "--merged"?
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..12711b29cf 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -28,9 +28,10 @@
> #include "help.h"
> #include "advice.h"
> #include "commit-reach.h"
> +#include "wildmatch.h"
> > static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
> + N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
> N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
> N_("git branch [<options>] [-l] [<pattern>...]"),
> N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
> @@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
> return strbuf_detach(&fmt, NULL);
> }
> > +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams);
We try to avoid forward declarations unless they're really needed - can we add the new functions up here instead?
> static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
> - struct ref_format *format, struct string_list *output)
> + struct ref_format *format, struct string_list *output,
> + const struct string_list *forked_upstreams)
> {
> int i;
> struct ref_array array;
> @@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
> > filter_refs(&array, filter, filter->kind);
> > + if (forked_upstreams->nr)
> + filter_array_by_forked(&array, forked_upstreams);
This gets a bit messy below where free elements when we filter "array". It would be much nicer to do the filtering in apply_ref_filter() so that we don't have to allocate those in the first place. I think it would make it simpler to implement --prune-merged as collect_forked_set() would become a call to filter_refs() and we could support --forked in "git for-each-ref".
> +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
> +{
> + struct object_id oid;
> + char *full_ref = NULL;
> +
> + if (has_glob_specials(arg)) {
> + out->name = xstrdup(arg);
> + out->is_wildcard = 1;
> + return 0;
> + }
> +
> + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> + &full_ref, 0) == 1 &&
> + (starts_with(full_ref, "refs/heads/") ||
> + starts_with(full_ref, "refs/remotes/"))) {
> + out->name = xstrdup(short_upstream_name(full_ref));
I don't think abbreviating the refname here is a good idea as short names are inherently ambiguous - in principle you could have a remote tracking branch and a local branch with the same short name. It also means we end up reconstructing the full name in a later patch, instead we should just call short_upstream_name() where we need the abbreviated name.
> +static int upstream_matches(const char *short_upstream,
> + const struct upstream_pattern *patterns,
> + size_t nr)
> +{
> + size_t i;
> +
> + for (i = 0; i < nr; i++) {
> + const struct upstream_pattern *p = &patterns[i];
> + if (p->is_wildcard) {
> + if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
> + return 1;
> + } else if (!strcmp(p->name, short_upstream)) {
> + return 1;
> + }
> + }
This is quadratic but maybe we can assume the user wont pass "--forked" too many times. If this ever becomes a problem we could use an strset for the exact matches and then we only need to loop over the wildmatch patterns but we probably don't need to worry about that now.
> +static int branch_upstream_matches(const char *full_refname,
> + const struct upstream_pattern *patterns,
> + size_t nr_patterns)
> +{
> + const char *short_name;
> + struct branch *branch;
> + const char *upstream;
> +
> + if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> + return 0;
> + branch = branch_get(short_name);
> + if (!branch)
> + return 0;
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> + return upstream_matches(short_upstream_name(upstream),
This would be simpler if we matched on full names.
> +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + int i, kept = 0;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> +
> + for (i = 0; i < array->nr; i++) {
> + struct ref_array_item *item = array->items[i];
> + if (branch_upstream_matches(item->refname,
> + patterns, nr_patterns))
> + array->items[kept++] = item;
> + else
> + free_ref_array_item(item);
> + }
> + array->nr = kept;
As I said above this would be nicer if it was implemented in apply_ref_filter().
> @@ -714,6 +847,7 @@ int cmd_branch(int argc,
> /* possible actions */
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> + struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
Personally I'd use a strvec here as we don't need the "util" member of the string list but I'm probably biased as I don't really like the string list api.
I like the idea of making this just another filter to "--list". The basics of the implementation look reasonable - it should be straight forward to match on full refs and move the relavent code into filter-refs.c
Thanks
Phillip
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -767,6 +901,8 @@ int cmd_branch(int argc,
> OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
> OPT_BOOL(0, "edit-description", &edit_description,
> N_("edit the description for the branch")),
> + OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> + N_("list local branches whose upstream matches <branch> (repeatable)")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -815,7 +951,8 @@ int cmd_branch(int argc,
> list = 1;
> > if (filter.with_commit || filter.no_commit ||
> - filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
> + filter.reachable_from || filter.unreachable_from ||
> + filter.points_at.nr || forked_upstreams.nr)
> list = 1;
> > noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> @@ -880,7 +1017,8 @@ int cmd_branch(int argc,
> ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
> ref_sorting_set_sort_flags_all(
> sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
> - print_ref_list(&filter, sorting, &format, &output);
> + print_ref_list(&filter, sorting, &format, &output,
> + &forked_upstreams);
> print_columns(&output, colopts, NULL);
> string_list_clear(&output, 0);
> ref_sorting_release(sorting);
> @@ -1020,5 +1158,6 @@ int cmd_branch(int argc,
> > out:
> string_list_clear(&sorting_options, 0);
> + string_list_clear(&forked_upstreams, 0);
> return ret;
> }
> diff --git a/ref-filter.c b/ref-filter.c
> index 1da4c0e60d..65e7bc6785 100644
> --- a/ref-filter.c
> +++ b/ref-filter.c
> @@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)
> }
> > /* Free memory allocated for a ref_array_item */
> -static void free_array_item(struct ref_array_item *item)
> +void free_ref_array_item(struct ref_array_item *item)
> {
> free((char *)item->symref);
> if (item->value) {
> @@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)
> > strbuf_release(&output);
> strbuf_release(&err);
> - free_array_item(item);
> + free_ref_array_item(item);
> > /*
> * Increment the running count of refs that match the filter. If
> @@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)
> int i;
> > for (i = 0; i < array->nr; i++)
> - free_array_item(array->items[i]);
> + free_ref_array_item(array->items[i]);
> FREE_AND_NULL(array->items);
> array->nr = array->alloc = 0;
> > @@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,
> if (is_merged == include_reached)
> array->items[array->nr++] = array->items[i];
> else
> - free_array_item(item);
> + free_ref_array_item(item);
> }
> > clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
> @@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,
> > strbuf_release(&err);
> strbuf_release(&output);
> - free_array_item(ref_item);
> + free_ref_array_item(ref_item);
> }
> > static int parse_sorting_atom(const char *atom)
> diff --git a/ref-filter.h b/ref-filter.h
> index 120221b47f..3883b9dc62 100644
> --- a/ref-filter.h
> +++ b/ref-filter.h
> @@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
> struct ref_format *format);
> /* Clear all memory allocated to ref_array */
> void ref_array_clear(struct ref_array *array);
> +/* Free a single item from a ref_array */
> +void free_ref_array_item(struct ref_array_item *item);
> /* Used to verify if the given format is correct and to parse out the used atoms */
> int verify_ref_format(struct ref_format *format);
> /* Sort the given ref_array as per the ref_sorting provided */
> 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 &&
> + 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 &&
> + 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 &&
> + 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_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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): Hi Phillip!
Great points all around, I will take a look at implementing them. I'll
respond here instead of for each specific message, and then include
comments as part of the next version.
> > Add a --forked option to "git branch" list mode that keeps only
> > branches whose configured upstream matches <branch>. The argument
> > can be a ref (e.g. "origin/main", "master") or a shell-style
> > glob (e.g. "origin/*"). The option can be repeated to widen the
> > filter.
>
> Do we want to support a remote name as an alias for $remote/HEAD to
> match "git checkout -b $remote"?
I have been going back and forth on this, and while I like the bare
remote, it made the implementation a lot easier after it was removed,
as the arguments from some of the others made sense to me.
Harald |
||
| [--contains [<commit>]] [--no-contains [<commit>]] | ||
| [(--forked <branch>)...] | ||
| [--points-at <object>] [--format=<format>] | ||
| [(-r|--remotes) | (-a|--all)] | ||
| [--list] [<pattern>...] | ||
|
|
@@ -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. Choose a reason for hiding this commentThe 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]There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > +`--all-remotes`::
> + With `--forked` or `--prune-merged`, act on every
> + configured remote in addition to any explicit _<remote>_
> + arguments.
Why do we except additional arguments if this option includes all the remotes?
Thanks
Phillip
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index bc4f4a4a18..7d45bada45 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -687,6 +687,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
> free_worktrees(worktrees);
> }
> > +static int collect_remote_name(struct remote *remote, void *cb_data)
> +{
> + struct string_list *remote_names = cb_data;
> + string_list_insert(remote_names, remote->name);
> + return 0;
> +}
> +
> static void parse_forked_args(int argc, const char **argv,
> struct string_list *remote_names,
> struct string_list *tracking_refs)
> @@ -776,7 +783,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,
> }
> }
> > -static void collect_forked_set(int argc, const char **argv,
> +static void collect_forked_set(int argc, const char **argv, int all_remotes,
> struct string_list *protected_default_refs,
> struct string_list *out)
> {
> @@ -789,6 +796,8 @@ static void collect_forked_set(int argc, const char **argv,
> };
> > parse_forked_args(argc, argv, &remote_names, &tracking_refs);
> + if (all_remotes)
> + for_each_remote(collect_remote_name, &remote_names);
> > refs_for_each_branch_ref(get_main_ref_store(the_repository),
> collect_forked_branch, &cb);
> @@ -802,15 +811,15 @@ static void collect_forked_set(int argc, const char **argv,
> string_list_clear(&tracking_refs, 0);
> }
> > -static int list_forked_branches(int argc, const char **argv)
> +static int list_forked_branches(int argc, const char **argv, int all_remotes)
> {
> struct string_list out = STRING_LIST_INIT_DUP;
> struct string_list_item *item;
> > - if (!argc)
> - die(_("--forked requires at least one <remote>"));
> + if (!argc && !all_remotes)
> + die(_("--forked requires at least one <remote> or --all-remotes"));
> > - collect_forked_set(argc, argv, NULL, &out);
> + collect_forked_set(argc, argv, all_remotes, NULL, &out);
> for_each_string_list_item(item, &out)
> puts(item->string);
> > @@ -818,7 +827,8 @@ static int list_forked_branches(int argc, const char **argv)
> return 0;
> }
> > -static int prune_merged_branches(int argc, const char **argv, int quiet)
> +static int prune_merged_branches(int argc, const char **argv,
> + int all_remotes, int quiet)
> {
> struct string_list candidates = STRING_LIST_INIT_DUP;
> struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
> @@ -827,10 +837,11 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
> int n_not_merged = 0;
> int ret = 0;
> > - if (!argc)
> - die(_("--prune-merged requires at least one <remote>"));
> + if (!argc && !all_remotes)
> + die(_("--prune-merged requires at least one <remote> or --all-remotes"));
> > - collect_forked_set(argc, argv, &protected_default_refs, &candidates);
> + collect_forked_set(argc, argv, all_remotes, &protected_default_refs,
> + &candidates);
> > for_each_string_list_item(item, &candidates) {
> const char *short_name = item->string;
> @@ -943,6 +954,7 @@ int cmd_branch(int argc,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> int forked = 0;
> int prune_merged = 0;
> + int all_remotes = 0;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -1000,6 +1012,9 @@ int cmd_branch(int argc,
> N_("list local branches forked from the given <remote>s")),
> OPT_BOOL(0, "prune-merged", &prune_merged,
> N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
> + OPT_BOOL_F(0, "all-remotes", &all_remotes,
> + N_("with --forked or --prune-merged, act on every configured remote"),
> + PARSE_OPT_NONEG),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -1043,6 +1058,10 @@ int cmd_branch(int argc,
> argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
> 0);
> > + if (all_remotes && !forked && !prune_merged)
> + die(_("--all-remotes requires --forked or --prune-merged"));
> +
> +
> if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> !show_current && !unset_upstream && !forked && !prune_merged &&
> argc == 0)
> @@ -1096,10 +1115,10 @@ int cmd_branch(int argc,
> quiet, 0, NULL);
> goto out;
> } else if (forked) {
> - ret = list_forked_branches(argc, argv);
> + ret = list_forked_branches(argc, argv, all_remotes);
> goto out;
> } else if (prune_merged) {
> - ret = prune_merged_branches(argc, argv, quiet);
> + ret = prune_merged_branches(argc, argv, all_remotes, quiet);
> goto out;
> } else if (show_current) {
> print_current_branch_name();
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 885a275e36..a36e5ee80a 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '
> test_grep "at least one <remote>" err
> '
> > +test_expect_success '--forked --all-remotes covers every configured remote' '
> + git -C forked branch --forked --all-remotes >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked --all-remotes still validates explicit <remote>' '
> + test_must_fail git -C forked branch --forked nope --all-remotes 2>err &&
> + test_grep "neither a configured remote nor a remote-tracking branch" err
> +'
> +
> +test_expect_success '--all-remotes alone is rejected' '
> + test_must_fail git -C forked branch --all-remotes 2>err &&
> + test_grep "requires --forked or --prune-merged" err
> +'
> +
> test_expect_success '--prune-merged: setup' '
> test_create_repo pm-upstream &&
> test_commit -C pm-upstream base &&
> @@ -1881,4 +1902,27 @@ 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 --all-remotes covers every configured remote' '
> + test_when_finished "rm -rf pm-allremotes pm-other" &&
> + git clone pm-upstream pm-allremotes &&
> + test_create_repo pm-other &&
> + test_commit -C pm-other other-base &&
> + git -C pm-other checkout -b stable &&
> + test_commit -C pm-other foreign-commit &&
> + git -C pm-other branch foreign HEAD &&
> + git -C pm-other checkout main &&
> +
> + git -C pm-allremotes remote add other ../pm-other &&
> + git -C pm-allremotes fetch other &&
> + git -C pm-allremotes branch one one-commit &&
> + git -C pm-allremotes branch --set-upstream-to=origin/next one &&
> + git -C pm-allremotes branch foreign other/foreign &&
> + git -C pm-allremotes branch --set-upstream-to=other/stable foreign &&
> +
> + git -C pm-allremotes branch --prune-merged --all-remotes &&
> +
> + test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign
> +'
> +
> test_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..1e24c95a69 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -28,6 +28,7 @@
> #include "help.h"
> #include "advice.h"
> #include "commit-reach.h"
> +#include "wildmatch.h"
>
> static const char * const builtin_branch_usage[] = {
> N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
> @@ -38,6 +39,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>] --forked <branch>..."),
> NULL
> };
>
> @@ -191,7 +193,8 @@ static int branch_merged(int kind, const char *name,
>
> static int check_branch_commit(const char *branchname, const char *refname,
> const struct object_id *oid, struct commit *head_rev,
> - int kinds, int force)
> + int kinds, int force, int warn_only,
> + int *n_not_merged)
> {
> struct commit *rev = lookup_commit_reference(the_repository, oid);
> if (!force && !rev) {
> @@ -199,10 +202,18 @@ static int check_branch_commit(const char *branchname, const char *refname,
> return -1;
> }
> if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
> - error(_("the branch '%s' is not fully merged"), branchname);
> - advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> - _("If you are sure you want to delete it, "
> - "run 'git branch -D %s'"), branchname);
> + if (warn_only) {
> + warning(_("the branch '%s' is not fully merged"),
> + branchname);
> + } else {
> + error(_("the branch '%s' is not fully merged"),
> + branchname);
> + advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> + _("If you are sure you want to delete it, "
> + "run 'git branch -D %s'"), branchname);
> + }
> + if (n_not_merged)
> + (*n_not_merged)++;
> return -1;
> }
> return 0;
This function originally was to see if "git branch -d <derived>" is
allowed to remove it by calling branch_merged(), which uses the
upstream association of <derived>, falling back to head_rev. For
our purpose of "--forked" check, we are not deleting, so existing
calls to error() and the phrasing used in its messages that talks
about deletion are not appropriate. Hence we introduce the
warn_only mode to just state the fact the branch is not fully merged
to its upstream.
It is inherited from the original code, and it is outside the scope
of this series, but after the dust settles, we may want to consider
saying what other branch this branch is expected to be merged to
(i.e. "not fully merged to %s").
It is unclear how n_not_merged would be useful. Wouldn't the caller
be capable to count the failure returns from this function?
> @@ -218,7 +229,7 @@ static void delete_branch_config(const char *branchname)
> }
>
> static int delete_branches(int argc, const char **argv, int force, int kinds,
> - int quiet)
> + int quiet, int warn_only, int *n_not_merged)
It is strange to see any need to touch delete_branches() when the
only thing you are adding is the "--forked" option. For that
matter, the change to check_branch_commit() is also suspect.
In short, this does a lot more than necessary to add "--forked".
Why such a split in the series?
> +static void parse_forked_args(int argc, const char **argv,
> + struct string_list *upstream_patterns)
> +{
> + int i;
> +
> + for (i = 0; i < argc; i++) {
> + const char *arg = argv[i];
> + struct object_id oid;
> + char *full_ref = NULL;
> + const char *short_ref;
> +
> + if (has_glob_specials(arg)) {
> + string_list_insert(upstream_patterns, arg);
> + continue;
> + }
> +
> + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> + &full_ref, 0) == 1 &&
> + (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
> + skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
> + string_list_insert(upstream_patterns, short_ref);
> + free(full_ref);
> + continue;
> + }
> + free(full_ref);
> +
> + die(_("'%s' is not a valid branch or pattern"), arg);
> + }
> +}
This one does look like very much relevant to "--forked".
> +struct forked_cb {
> + const struct string_list *upstream_patterns;
> + struct string_list *out;
> +};
So does this.
> +static int collect_forked_branch(const struct reference *ref, void *cb_data)
> +{
> + struct forked_cb *cb = cb_data;
> + struct branch *branch;
> + const char *upstream, *short_upstream;
> + const struct string_list_item *item;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> + branch = branch_get(ref->name);
> + if (!branch)
> + return 0;
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> + short_upstream = upstream;
> + (void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
> + skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
> +
> + for_each_string_list_item(item, cb->upstream_patterns)
Sholdn't each element of upstream_patterns remember if it is
suitable for wildmatch or not when it gets parsed in the earlier
function we saw, so that this loop can refrain from calling
wildmatch() for non-wildcard elements?
> + if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
> + string_list_append(cb->out, ref->name)->util =
> + xstrdup(upstream);
> + return 0;
> + }
> + return 0;
> +}
> +
> +static void collect_forked_set(int argc, const char **argv,
> + struct string_list *out)
> +{
> + struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
> + struct forked_cb cb = {
> + .upstream_patterns = &upstream_patterns,
> + .out = out,
> + };
> +
> + parse_forked_args(argc, argv, &upstream_patterns);
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> + string_list_clear(&upstream_patterns, 0);
> +}
> +
> +static int list_forked_branches(int argc, const char **argv)
> +{
> + struct string_list out = STRING_LIST_INIT_DUP;
> + struct string_list_item *item;
> +
> + if (!argc)
> + die(_("--forked requires at least one <branch>"));
> +
> + collect_forked_set(argc, argv, &out);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> + string_list_clear(&out, 1);
> + return 0;
> +}
Up to point the changes are very much appropriate for adding the
"--forked" option.
> @@ -714,6 +822,7 @@ int cmd_branch(int argc,
> /* possible actions */
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> + int forked = 0;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -767,6 +876,8 @@ int cmd_branch(int argc,
> OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
> OPT_BOOL(0, "edit-description", &edit_description,
> N_("edit the description for the branch")),
> + OPT_BOOL(0, "forked", &forked,
> + N_("list local branches whose upstream matches the given <branch>...")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -811,7 +922,7 @@ int cmd_branch(int argc,
> 0);
>
> if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && argc == 0)
> + !show_current && !unset_upstream && !forked && argc == 0)
> list = 1;
>
> if (filter.with_commit || filter.no_commit ||
> @@ -820,7 +931,7 @@ int cmd_branch(int argc,
>
> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream;
> + !!unset_upstream + !!forked;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
>
> @@ -858,7 +969,11 @@ int cmd_branch(int argc,
> if (delete) {
> if (!argc)
> die(_("branch name required"));
> - ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> + ret = delete_branches(argc, argv, delete > 1, filter.kind,
> + quiet, 0, NULL);
> + goto out;
This does not belong to "--forked" as far as I can tell.
> + } else if (forked) {
> + ret = list_forked_branches(argc, argv);
> goto out;
> } else if (show_current) {
> print_current_branch_name();There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Johannes Sixt wrote on the Git mailing list (how to reply to this email): Am 22.05.26 um 00:40 schrieb Harald Nordgren via GitGitGadget:
> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index c0afddc424..3a421f6663 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -24,6 +24,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 --forked <branch>...
I would have preferred that this option is another filter of --list
mode, not its own mode of operation. Consequently, each --forked option
would take only a single argument (which can contain globs), and can be
given multiple times.
>
> DESCRIPTION
> -----------
> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
>
> +`--forked`::
> + List local branches whose configured upstream matches any
> + of the given _<branch>_ arguments. Each argument is either
> + a ref (e.g. `origin/master`, `master`) or a shell-style
> + glob (e.g. `'origin/*'`). Multiple arguments are unioned.
So this could perhaps read:
`--forked`::
List only branches whose configured upstream matches
_<branch>_. The argument can contain a shell-style glob
(e.g. `'origin/*'`). The option can be repeated to
widen the filter.
Note that there is no reason to say "local branches". ("... are unioned"
sounds strange, so this is may attempt to express the same in a
different way.)
The icing on the cake would now be that
git branch --merged origin/main --forked origin/*
provides the list of branches forked from origin that have already been
integrated.
-- Hannes
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): Johannes Sixt <j6t@kdbg.org> writes:
> The icing on the cake would now be that
>
> git branch --merged origin/main --forked origin/*
>
> provides the list of branches forked from origin that have already been
> integrated.
Yup, that is very nice. Also with "--merged" replaced with
"--not-merged", i.e., "our work building on top of origin's, and
still need to be finished", would give us a good list to work on.There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): > Johannes Sixt <j6t@kdbg.org> writes:
>
> > The icing on the cake would now be that
> >
> > git branch --merged origin/main --forked origin/*
> >
> > provides the list of branches forked from origin that have already been
> > integrated.
>
> Yup, that is very nice. Also with "--merged" replaced with
> "--not-merged", i.e., "our work building on top of origin's, and
> still need to be finished", would give us a good list to work on.
This is nice, but I think this would require an overhaul of other
infra as well, maybe better to do as a follow-up?
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Johannes Sixt wrote on the Git mailing list (how to reply to this email): Am 22.05.26 um 12:49 schrieb Harald Nordgren:
>> Johannes Sixt <j6t@kdbg.org> writes:
>>
>>> The icing on the cake would now be that
>>>
>>> git branch --merged origin/main --forked origin/*
>>>
>>> provides the list of branches forked from origin that have already been
>>> integrated.
>>
>> Yup, that is very nice. Also with "--merged" replaced with
>> "--not-merged", i.e., "our work building on top of origin's, and
>> still need to be finished", would give us a good list to work on.
>
> This is nice, but I think this would require an overhaul of other
> infra as well, maybe better to do as a follow-up?
This can certainly be done as an extension in a follow-up patch. But the
UI must still be planned accordingly, i.e., --forked can only take a
single argument. For example, in
git branch --forked foo bar
'bar' is the pattern of branches to show. The "list" is filtered
according to '--forked foo'. That is, if 'bar' was not forked from
'foo', the output is empty.
You would have to require
git branch --forked foo --forked bar
to list all branches forked from 'foo' or 'bar'.
In the first implementation, you can restrict uses of other options with
--forked or even with a branch pattern. But you cannot be loose by
accepting multiple branch patterns with one --forked option, because
that would later clash with --list mode.
-- Hannes
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > git branch --prune-merged <branch>...
I'm not sure that synopsis is correct anymore as you need to repeat "--prune-merged". As --prune-merged now takes an argument there is no reason to forbid positional arguments so I think we should support
git branch --prune-merged origin/master 'feature*'
to delete all the branches beginning with "feature" that have the upstream "origin/master" and have been merged.
I wonder about the name - the other options that delete branches are called "delete", not "prune". Also "--prune-merged" does not delete the branches listed by "--merged" so maybe "--delete-forked" would be better?
I've not commented in detail on the code as it will need to change a bit once we match on full refnames and do the filtering in apply_ref_filter() but I think the basics are sound.
I'll stop here - I did quickly scan the next two patches and they both looked like sensible ideas.
Thanks
Phillip
> deletes the local branches that "--forked <branch>" would list,
> restricted to 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. Users
> who want fresh upstream refs run "git fetch" first.
> > Three classes of branches are spared:
> > * any branch checked out in any worktree;
> * any branch whose upstream no longer resolves locally (its
> disappearance is not, on its own, evidence of integration);
> * any branch whose push destination equals its upstream
> (<branch>@{push} == <branch>@{upstream}). Such a branch
> cannot be distinguished from a freshly pulled trunk that
> just looks "fully merged", e.g. local "main" tracking and
> pushing to "origin/main" right after a pull. Only branches
> that push somewhere other than their upstream (typically
> topics in a fork-based workflow) are treated as candidates.
> > Deletion goes through the existing delete_branches() in warn-only
> mode and with the HEAD-fallback disabled: a branch that is not
> yet fully merged to its upstream is reported as a one-line warning
> and skipped, so a single un-mergeable topic does not abort the
> whole sweep. We only act on upstream-merged status.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 23 +++++
> builtin/branch.c | 117 +++++++++++++++++++--
> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
> 3 files changed, 318 insertions(+), 10 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 8002d7f38c..f7942fcd7d 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
> -----------
> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
> `master`) or a shell-style glob (e.g. `'origin/*'`). The
> option can be repeated to widen the filter.
> > +`--prune-merged <branch>`::
> + Delete the local branches that `--forked` would list for the
> + same _<branch>_, 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. May be given more than once to
> + union the matches; positional arguments are not accepted.
> ++
> +Reachability is checked against whatever the upstream refs say
> +locally; nothing is fetched. Run `git fetch` first if you want
> +the upstream refs refreshed.
> ++
> +A branch is left alone if any of the following holds:
> +its upstream no longer resolves locally; it is checked out in any
> +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".
> ++
> +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.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 09afdd9257..736480b002 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -39,6 +39,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
> };
> > @@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,
> return 0;
> }
> > -static int branch_upstream_matches(const char *full_refname,
> +static int branch_upstream_matches(const char *short_branch_name,
> const struct upstream_pattern *patterns,
> size_t nr_patterns)
> {
> - const char *short_name;
> - struct branch *branch;
> + struct branch *branch = branch_get(short_branch_name);
> const char *upstream;
> > - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> - return 0;
> - branch = branch_get(short_name);
> if (!branch)
> return 0;
> upstream = branch_get_upstream(branch, NULL);
> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,
> > for (i = 0; i < array->nr; i++) {
> struct ref_array_item *item = array->items[i];
> - if (branch_upstream_matches(item->refname,
> - patterns, nr_patterns))
> + const char *short_name;
> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
> + branch_upstream_matches(short_name, patterns, nr_patterns))
> array->items[kept++] = item;
> else
> free_ref_array_item(item);
> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,
> upstream_pattern_list_clear(patterns, nr_patterns);
> }
> > +struct forked_cb {
> + const struct upstream_pattern *patterns;
> + size_t nr_patterns;
> + struct string_list *out;
> +};
> +
> +static int collect_forked_branch(const struct reference *ref, void *cb_data)
> +{
> + struct forked_cb *cb = cb_data;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> + if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
> + string_list_append(cb->out, ref->name);
> + return 0;
> +}
> +
> +static void collect_forked_set(const struct string_list *upstreams,
> + struct string_list *out)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + struct forked_cb cb;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> + cb.patterns = patterns;
> + cb.nr_patterns = nr_patterns;
> + cb.out = out;
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> + string_list_sort(out);
> +
> + upstream_pattern_list_clear(patterns, nr_patterns);
> +}
> +
> +static int prune_merged_branches(const struct string_list *upstreams,
> + int quiet)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct string_list_item *item;
> + int ret = 0;
> +
> + if (!upstreams->nr)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(upstreams, &candidates);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + struct branch *branch = branch_get(short_name);
> + const char *upstream, *push;
> + struct strbuf full = STRBUF_INIT;
> + int skip;
> +
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + skip = !!branch_checked_out(full.buf);
> + strbuf_release(&full);
> + if (skip)
> + continue;
> +
> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> + if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> + push = branch ? branch_get_push(branch, NULL) : NULL;
> + if (!push || !strcmp(push, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 0, /* force */
> + FILTER_REFS_BRANCHES,
> + quiet,
> + 1, /* warn_only */
> + 1, /* no_head_fallback */
> + 0 /* dry_run */);
> +
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> + return ret;
> +}
> +
> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
> > static int edit_branch_description(const char *branch_name)
> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
> N_("edit the description for the branch")),
> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> N_("list local branches whose upstream matches <branch> (repeatable)")),
> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
> + N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,
> 0);
> > if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && argc == 0)
> + !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
> + argc == 0)
> list = 1;
> > if (filter.with_commit || filter.no_commit ||
> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
> > noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream;
> + !!unset_upstream + !!prune_merged_upstreams.nr;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
> > @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
> ret = delete_branches(argc, argv, delete > 1, filter.kind,
> quiet, 0, 0, 0);
> goto out;
> + } else if (prune_merged_upstreams.nr) {
> + if (argc)
> + die(_("--prune-merged does not take positional arguments; "
> + "repeat --prune-merged for each <branch>"));
> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
> + goto out;
> } else if (show_current) {
> print_current_branch_name();
> ret = 0;
> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
> out:
> string_list_clear(&sorting_options, 0);
> string_list_clear(&forked_upstreams, 0);
> + string_list_clear(&prune_merged_upstreams, 0);
> return ret;
> }
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..beb86987ad 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,192 @@ 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 --prune-merged 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 a value' '
> + test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "requires a value" err
> +'
> +
> +test_expect_success '--prune-merged rejects positional arguments' '
> + test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
> + test_grep "does not take positional arguments" err
> +'
> +
> test_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Phillip Wood wrote on the Git mailing list (how to reply to this email): On 05/06/2026 14:50, Phillip Wood wrote:
> > I wonder about the name - the other options that delete branches are > called "delete", not "prune". Also "--prune-merged" does not delete the > branches listed by "--merged" so maybe "--delete-forked" would be better?
"delete-forked" doesn't capture the fact the branch has been merged though - I wonder if anyone has a better idea
Thanks
Phillip
> I've not commented in detail on the code as it will need to change a bit > once we match on full refnames and do the filtering in > apply_ref_filter() but I think the basics are sound.
> > I'll stop here - I did quickly scan the next two patches and they both > looked like sensible ideas.
> > Thanks
> > Phillip
> >> deletes the local branches that "--forked <branch>" would list,
>> restricted to 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. Users
>> who want fresh upstream refs run "git fetch" first.
>>
>> Three classes of branches are spared:
>>
>> * any branch checked out in any worktree;
>> * any branch whose upstream no longer resolves locally (its
>> disappearance is not, on its own, evidence of integration);
>> * any branch whose push destination equals its upstream
>> (<branch>@{push} == <branch>@{upstream}). Such a branch
>> cannot be distinguished from a freshly pulled trunk that
>> just looks "fully merged", e.g. local "main" tracking and
>> pushing to "origin/main" right after a pull. Only branches
>> that push somewhere other than their upstream (typically
>> topics in a fork-based workflow) are treated as candidates.
>>
>> Deletion goes through the existing delete_branches() in warn-only
>> mode and with the HEAD-fallback disabled: a branch that is not
>> yet fully merged to its upstream is reported as a one-line warning
>> and skipped, so a single un-mergeable topic does not abort the
>> whole sweep. We only act on upstream-merged status.
>>
>> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>> ---
>> Documentation/git-branch.adoc | 23 +++++
>> builtin/branch.c | 117 +++++++++++++++++++--
>> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
>> 3 files changed, 318 insertions(+), 10 deletions(-)
>>
>> diff --git a/Documentation/git-branch.adoc b/Documentation/git- >> branch.adoc
>> index 8002d7f38c..f7942fcd7d 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
>> -----------
>> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
>> `master`) or a shell-style glob (e.g. `'origin/*'`). The
>> option can be repeated to widen the filter.
>> +`--prune-merged <branch>`::
>> + Delete the local branches that `--forked` would list for the
>> + same _<branch>_, 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. May be given more than once to
>> + union the matches; positional arguments are not accepted.
>> ++
>> +Reachability is checked against whatever the upstream refs say
>> +locally; nothing is fetched. Run `git fetch` first if you want
>> +the upstream refs refreshed.
>> ++
>> +A branch is left alone if any of the following holds:
>> +its upstream no longer resolves locally; it is checked out in any
>> +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".
>> ++
>> +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.
>> +
>> `-v`::
>> `-vv`::
>> `--verbose`::
>> diff --git a/builtin/branch.c b/builtin/branch.c
>> index 09afdd9257..736480b002 100644
>> --- a/builtin/branch.c
>> +++ b/builtin/branch.c
>> @@ -39,6 +39,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
>> };
>> @@ -782,17 +783,13 @@ static int upstream_matches(const char >> *short_upstream,
>> return 0;
>> }
>> -static int branch_upstream_matches(const char *full_refname,
>> +static int branch_upstream_matches(const char *short_branch_name,
>> const struct upstream_pattern *patterns,
>> size_t nr_patterns)
>> {
>> - const char *short_name;
>> - struct branch *branch;
>> + struct branch *branch = branch_get(short_branch_name);
>> const char *upstream;
>> - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
>> - return 0;
>> - branch = branch_get(short_name);
>> if (!branch)
>> return 0;
>> upstream = branch_get_upstream(branch, NULL);
>> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct >> ref_array *array,
>> for (i = 0; i < array->nr; i++) {
>> struct ref_array_item *item = array->items[i];
>> - if (branch_upstream_matches(item->refname,
>> - patterns, nr_patterns))
>> + const char *short_name;
>> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
>> + branch_upstream_matches(short_name, patterns, nr_patterns))
>> array->items[kept++] = item;
>> else
>> free_ref_array_item(item);
>> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct >> ref_array *array,
>> upstream_pattern_list_clear(patterns, nr_patterns);
>> }
>> +struct forked_cb {
>> + const struct upstream_pattern *patterns;
>> + size_t nr_patterns;
>> + struct string_list *out;
>> +};
>> +
>> +static int collect_forked_branch(const struct reference *ref, void >> *cb_data)
>> +{
>> + struct forked_cb *cb = cb_data;
>> +
>> + if (ref->flags & REF_ISSYMREF)
>> + return 0;
>> + if (branch_upstream_matches(ref->name, cb->patterns, cb- >> >nr_patterns))
>> + string_list_append(cb->out, ref->name);
>> + return 0;
>> +}
>> +
>> +static void collect_forked_set(const struct string_list *upstreams,
>> + struct string_list *out)
>> +{
>> + struct upstream_pattern *patterns = NULL;
>> + size_t nr_patterns = 0;
>> + struct forked_cb cb;
>> +
>> + parse_forked_args(upstreams, &patterns, &nr_patterns);
>> + cb.patterns = patterns;
>> + cb.nr_patterns = nr_patterns;
>> + cb.out = out;
>> +
>> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
>> + collect_forked_branch, &cb);
>> +
>> + string_list_sort(out);
>> +
>> + upstream_pattern_list_clear(patterns, nr_patterns);
>> +}
>> +
>> +static int prune_merged_branches(const struct string_list *upstreams,
>> + int quiet)
>> +{
>> + struct ref_store *refs = get_main_ref_store(the_repository);
>> + struct string_list candidates = STRING_LIST_INIT_DUP;
>> + struct strvec deletable = STRVEC_INIT;
>> + struct string_list_item *item;
>> + int ret = 0;
>> +
>> + if (!upstreams->nr)
>> + die(_("--prune-merged requires at least one <branch>"));
>> +
>> + collect_forked_set(upstreams, &candidates);
>> +
>> + for_each_string_list_item(item, &candidates) {
>> + const char *short_name = item->string;
>> + struct branch *branch = branch_get(short_name);
>> + const char *upstream, *push;
>> + struct strbuf full = STRBUF_INIT;
>> + int skip;
>> +
>> + strbuf_addf(&full, "refs/heads/%s", short_name);
>> + skip = !!branch_checked_out(full.buf);
>> + strbuf_release(&full);
>> + if (skip)
>> + continue;
>> +
>> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
>> + if (!upstream || !refs_ref_exists(refs, upstream))
>> + continue;
>> + push = branch ? branch_get_push(branch, NULL) : NULL;
>> + if (!push || !strcmp(push, upstream))
>> + continue;
>> +
>> + strvec_push(&deletable, short_name);
>> + }
>> +
>> + if (deletable.nr)
>> + ret = delete_branches(deletable.nr, deletable.v,
>> + 0, /* force */
>> + FILTER_REFS_BRANCHES,
>> + quiet,
>> + 1, /* warn_only */
>> + 1, /* no_head_fallback */
>> + 0 /* dry_run */);
>> +
>> + strvec_clear(&deletable);
>> + string_list_clear(&candidates, 0);
>> + return ret;
>> +}
>> +
>> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
>> static int edit_branch_description(const char *branch_name)
>> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
>> int delete = 0, rename = 0, copy = 0, list = 0,
>> unset_upstream = 0, show_current = 0, edit_description = 0;
>> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
>> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
>> const char *new_upstream = NULL;
>> int noncreate_actions = 0;
>> /* possible options */
>> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
>> N_("edit the description for the branch")),
>> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
>> N_("list local branches whose upstream matches <branch> >> (repeatable)")),
>> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, >> N_("branch"),
>> + N_("delete local branches whose upstream matches <branch> >> and is merged (repeatable)")),
>> OPT__FORCE(&force, N_("force creation, move/rename, >> deletion"), PARSE_OPT_NOCOMPLETE),
>> OPT_MERGED(&filter, N_("print only branches that are merged")),
>> OPT_NO_MERGED(&filter, N_("print only branches that are not >> merged")),
>> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,
>> 0);
>> if (!delete && !rename && !copy && !edit_description && ! >> new_upstream &&
>> - !show_current && !unset_upstream && argc == 0)
>> + !show_current && !unset_upstream && ! >> prune_merged_upstreams.nr &&
>> + argc == 0)
>> list = 1;
>> if (filter.with_commit || filter.no_commit ||
>> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
>> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
>> !!show_current + !!list + !!edit_description +
>> - !!unset_upstream;
>> + !!unset_upstream + !!prune_merged_upstreams.nr;
>> if (noncreate_actions > 1)
>> usage_with_options(builtin_branch_usage, options);
>> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
>> ret = delete_branches(argc, argv, delete > 1, filter.kind,
>> quiet, 0, 0, 0);
>> goto out;
>> + } else if (prune_merged_upstreams.nr) {
>> + if (argc)
>> + die(_("--prune-merged does not take positional arguments; "
>> + "repeat --prune-merged for each <branch>"));
>> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
>> + goto out;
>> } else if (show_current) {
>> print_current_branch_name();
>> ret = 0;
>> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
>> out:
>> string_list_clear(&sorting_options, 0);
>> string_list_clear(&forked_upstreams, 0);
>> + string_list_clear(&prune_merged_upstreams, 0);
>> return ret;
>> }
>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
>> index 4e7deddc04..beb86987ad 100755
>> --- a/t/t3200-branch.sh
>> +++ b/t/t3200-branch.sh
>> @@ -1809,4 +1809,192 @@ 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 --prune-merged >> 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 a value' '
>> + test_must_fail git -C forked branch --prune-merged 2>err &&
>> + test_grep "requires a value" err
>> +'
>> +
>> +test_expect_success '--prune-merged rejects positional arguments' '
>> + test_must_fail git -C forked branch --prune-merged origin/one >> other/foreign 2>err &&
>> + test_grep "does not take positional arguments" err
>> +'
>> +
>> test_done
> There was a problem hiding this comment. Choose a reason for hiding this commentThe 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_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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!
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| git branch (-d|-D) [-r] <branch-name>... | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> +`--prune-merged`::
> + Delete the local branches that `--forked` would list for
> + the same _<remote>_ arguments, but only when the branch's
> + push destination remote-tracking branch (the branch `git push`
> + would update; see `branch_get_push` semantics) no longer
> + resolves locally.
I thought the thing you were aiming for is this scenario:
git fetch origin
git checkout -t -b hn/topic origin/next
... work work work ...
git push origin
... the above pushes the current hn/topic to update their next
git checkout master ;# or anywhere other than hn/topic
git fetch origin
git branch --prune-merged origin
The last step notices that our local hn/topic has been merged at the
remote to the target branch 'next', and the second-to-last fetch
makes us notice that origin/next now has our hn/topic merged, so we
no longer have a reason to keep hn/topic around.
But the above description uses quite different condition. You want
to notice that _they_ removed 'next' (for that, the second-to-last
fetch may need to be run with --prune) and then remove our local
hn/topic, but to me, that sounds nonsense for two reasons.
(1) Their 'next' is something contributors may fork from to work
on. You exactly did that with hn/topic branch of your own.
Why would we even expect that to go away?
(2) When disappearance of their 'next' is fetched to our
remote-tracking namespace, we would not even know if hn/topic
that used to fork from has been already integrated and stashed
safely on some other branch on the remote. It sounds very
unsafe to remove it based on disappearance of origin/next
remote-tracking branch.
> In other words: the branch was pushed
> + under some name on _<remote>_, and that name has since
> + been pruned upstream.
> ++
> +As a safety check, branches with commits not yet integrated into
> +their upstream remote-tracking branch are refused; if the upstream
> +itself is gone, the remote's default branch is consulted instead.
Again, this is as nonsense as our example in an earlier iteration of
having a topic forked from my 'todo' branch while the HEAD is
pointing at the default branch that is 'master'. If the upstream
itself is gone, removing anything based on some other criteria
cannot by definition a "safety check". I'd suggest rethinking the
logic.
> @@ -171,7 +197,7 @@ static int branch_merged(int kind, const char *name,
> * any of the following code, but during the transition period,
> * a gentle reminder is in order.
> */
> - if (head_rev != reference_rev) {
> + if (!no_head_fallback && head_rev != reference_rev) {
I somehow thought that the necessary check at the lowest level can
reuse most of the "branch -d" protection logic, except that it needs
to pass NULL for head_rev from check_branch_commit() down to
branch_merged() when doing "branch --prune-merged". Do we really
need an extra no_head_fallback parameter?There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): > But the above description uses quite different condition. You want
> to notice that _they_ removed 'next' (for that, the second-to-last
> fetch may need to be run with --prune) and then remove our local
> hn/topic, but to me, that sounds nonsense for two reasons.
I wanted to be able to be aggresice with deleting, but maybe this went a
bit overboard. Would still be nice to have a nuclear option.
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Delete the local branches that --forked <remote> would list, but
> only those whose tip is reachable from their configured upstream
> remote-tracking branch (branch.<name>.merge): the work has already
> landed on the upstream it tracks, so the local copy is no longer
> needed.
I think being able to prune branches that have been merged into their upstream is a good idea. However, I find the focus on remotes rather than upstream branches in the UI a bit confusing. While the upstream of a branch is often a remote tracking branch it doesn't have to be. For my personal projects I often do
git checkout -b topic master
and it would be nice to be able to run
git branch --prune-merged master
to clean up those topics that have been merged. Similarly I think it is confusing that
git checkout -b topic origin
starts a branch from the default branch on origin, but if I run
git branch --prune-merged origin
to clean it up, it will clean up all the branches with an upstream on origin, not just those whose upstream matches origin/HEAD.
So I like the idea, but would prefer the arguments to --prune-merged to be upstream branches, not remotes. We could support globs so that
git branch --prune-merges 'origin/*'
would clean up all the branches whose upstream is on origin if that is useful.
Thanks
Phillip
> A branch whose upstream no longer resolves locally is left alone --
> its disappearance is not, on its own, evidence that the work was
> integrated. With --force, skip the reachability check and delete
> every branch in the candidate set. The currently checked-out
> branch in any worktree is always preserved, as is the local branch
> that mirrors <remote>'s default branch.
> > Reachability is read from whatever the remote-tracking refs say
> locally, so the natural workflow is
> > git fetch <remote>
> git branch --prune-merged <remote>
> > with no implicit cleanup driven by fetch itself.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 19 +++++
> builtin/branch.c | 143 +++++++++++++++++++++++++++++-----
> t/t3200-branch.sh | 83 ++++++++++++++++++++
> 3 files changed, 226 insertions(+), 19 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 5773104cd3..375a0a68da 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> git branch --forked <remote>...
> +git branch --prune-merged <remote>...
> > DESCRIPTION
> -----------
> @@ -211,6 +212,24 @@ Each _<remote>_ may be either the name of a configured remote
> `refs/remotes/origin/*` ref) or a specific remote-tracking branch
> (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.
> > +`--prune-merged`::
> + Delete the local branches that `--forked` would list for
> + the same _<remote>_ arguments, but only those whose tip is
> + reachable from their configured upstream remote-tracking
> + branch (`branch.<name>.merge`). In other words: the work on
> + the branch has already landed on the upstream it tracks, so
> + the local copy is no longer needed.
> ++
> +Run `git fetch` first so the upstream remote-tracking branches
> +reflect the current state of _<remote>_; reachability is checked
> +against whatever the remote-tracking refs say locally.
> ++
> +A branch whose upstream no longer resolves locally is left alone
> +(its disappearance is not, on its own, evidence that the work was
> +integrated). The currently checked-out branch in any worktree is
> +always preserved, as is the local branch that mirrors _<remote>_'s
> +default branch.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1941f8a9ad..6fe2ffd7e8 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -21,6 +21,7 @@
> #include "branch.h"
> #include "path.h"
> #include "string-list.h"
> +#include "strvec.h"
> #include "column.h"
> #include "utf8.h"
> #include "ref-filter.h"
> @@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,
> * any of the following code, but during the transition period,
> * a gentle reminder is in order.
> */
> - if (head_rev != reference_rev) {
> - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> + if (head_rev && head_rev != reference_rev) {
> + int expect = repo_in_merge_bases(the_repository, rev, head_rev);
> if (expect < 0)
> exit(128);
> if (expect == merged)
> @@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)
> strbuf_release(&buf);
> }
> > -static int delete_branches(int argc, const char **argv, int force, int kinds,
> +static int delete_branches(int argc, const char **argv,
> + int no_head_fallback,
> + int force, int kinds,
> int quiet, int warn_only, int *n_not_merged)
> {
> struct commit *head_rev = NULL;
> @@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> }
> branch_name_pos = strcspn(fmt, "%");
> > - if (!force)
> + if (!force && !no_head_fallback)
> head_rev = lookup_commit_reference(the_repository, &head_oid);
> > for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
> @@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> }
> > if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
> - check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
> - force, warn_only, n_not_merged)) {
> + check_branch_commit(bname.buf, name, &oid, head_rev,
> + kinds, force, warn_only, n_not_merged)) {
> if (!warn_only)
> ret = 1;
> goto next;
> @@ -753,36 +756,131 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
> return 0;
> }
> > -static int list_forked_branches(int argc, const char **argv)
> +static void collect_default_branch_refs(const struct string_list *remote_names,
> + struct string_list *out)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list_item *item;
> +
> + for_each_string_list_item(item, remote_names) {
> + struct strbuf head = STRBUF_INIT;
> + const char *target;
> +
> + strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
> + target = refs_resolve_ref_unsafe(refs, head.buf,
> + RESOLVE_REF_NO_RECURSE,
> + NULL, NULL);
> + if (target && starts_with(target, "refs/remotes/"))
> + string_list_insert(out, target);
> + strbuf_release(&head);
> + }
> +}
> +
> +static void collect_forked_set(int argc, const char **argv,
> + struct string_list *protected_default_refs,
> + struct string_list *out)
> {
> struct string_list remote_names = STRING_LIST_INIT_NODUP;
> struct string_list tracking_refs = STRING_LIST_INIT_DUP;
> - struct string_list out = STRING_LIST_INIT_DUP;
> - struct string_list_item *item;
> struct forked_cb cb = {
> .remote_names = &remote_names,
> .tracking_refs = &tracking_refs,
> - .out = &out,
> + .out = out,
> };
> > - if (!argc)
> - die(_("--forked requires at least one <remote>"));
> -
> parse_forked_args(argc, argv, &remote_names, &tracking_refs);
> > refs_for_each_branch_ref(get_main_ref_store(the_repository),
> collect_forked_branch, &cb);
> > - string_list_sort(&out);
> - for_each_string_list_item(item, &out)
> - puts(item->string);
> + string_list_sort(out);
> +
> + if (protected_default_refs)
> + collect_default_branch_refs(&remote_names, protected_default_refs);
> > string_list_clear(&remote_names, 0);
> string_list_clear(&tracking_refs, 0);
> +}
> +
> +static int list_forked_branches(int argc, const char **argv)
> +{
> + struct string_list out = STRING_LIST_INIT_DUP;
> + struct string_list_item *item;
> +
> + if (!argc)
> + die(_("--forked requires at least one <remote>"));
> +
> + collect_forked_set(argc, argv, NULL, &out);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> string_list_clear(&out, 0);
> return 0;
> }
> > +static int prune_merged_branches(int argc, const char **argv, int quiet)
> +{
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> + struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct string_list_item *item;
> + int n_not_merged = 0;
> + int ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <remote>"));
> +
> + collect_forked_set(argc, argv, &protected_default_refs, &candidates);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + struct strbuf full = STRBUF_INIT;
> + struct branch *branch;
> + const char *upstream;
> +
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + if (branch_checked_out(full.buf)) {
> + strbuf_release(&full);
> + continue;
> + }
> + strbuf_release(&full);
> +
> + branch = branch_get(short_name);
> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> + if (!upstream ||
> + !refs_ref_exists(get_main_ref_store(the_repository),
> + upstream))
> + continue;
> + if (string_list_has_string(&protected_default_refs, upstream)) {
> + const char *leaf = strrchr(upstream, '/');
> + if (leaf && !strcmp(leaf + 1, short_name))
> + continue;
> + }
> +
> + strvec_push(&deletable, short_name);
> + }
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 1, 0,
> + FILTER_REFS_BRANCHES, quiet,
> + 1, &n_not_merged);
> +
> + if (n_not_merged && !quiet)
> + fprintf(stderr,
> + Q_("Skipped %d branch that is not fully merged; "
> + "delete it with 'git branch -D' if you are sure.\n",
> + "Skipped %d branches that are not fully merged; "
> + "delete them with 'git branch -D' if you are sure.\n",
> + n_not_merged),
> + n_not_merged);
> +
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> + string_list_clear(&protected_default_refs, 0);
> + return ret;
> +}
> +
> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
> > static int edit_branch_description(const char *branch_name)
> @@ -825,6 +923,7 @@ int cmd_branch(int argc,
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> int forked = 0;
> + int prune_merged = 0;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -880,6 +979,8 @@ int cmd_branch(int argc,
> N_("edit the description for the branch")),
> OPT_BOOL(0, "forked", &forked,
> N_("list local branches forked from the given <remote>s")),
> + OPT_BOOL(0, "prune-merged", &prune_merged,
> + N_("delete local branches forked from the given <remote>s that are merged into their upstream")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -924,7 +1025,8 @@ int cmd_branch(int argc,
> 0);
> > if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && !forked && argc == 0)
> + !show_current && !unset_upstream && !forked && !prune_merged &&
> + argc == 0)
> list = 1;
> > if (filter.with_commit || filter.no_commit ||
> @@ -933,7 +1035,7 @@ int cmd_branch(int argc,
> > noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream + !!forked;
> + !!unset_upstream + !!forked + !!prune_merged;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
> > @@ -971,12 +1073,15 @@ int cmd_branch(int argc,
> if (delete) {
> if (!argc)
> die(_("branch name required"));
> - ret = delete_branches(argc, argv, delete > 1, filter.kind,
> + ret = delete_branches(argc, argv, 0, delete > 1, filter.kind,
> quiet, 0, NULL);
> goto out;
> } else if (forked) {
> ret = list_forked_branches(argc, argv);
> goto out;
> + } else if (prune_merged) {
> + ret = prune_merged_branches(argc, argv, quiet);
> + goto out;
> } else if (show_current) {
> print_current_branch_name();
> ret = 0;
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 24a3ec44ee..94ea493aee 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1771,4 +1771,87 @@ test_expect_success '--forked requires at least one <remote>' '
> test_grep "at least one <remote>" 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_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 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 spares branches with un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + 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 "Skipped 1 branch" err &&
> + test_grep "git branch -D" 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 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 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 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 the local default branch' '
> + test_when_finished "rm -rf pm-default" &&
> + git clone pm-upstream pm-default &&
> + git -C pm-default checkout --detach &&
> + git -C pm-default branch --prune-merged origin &&
> + git -C pm-default rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
> + test_when_finished "rm -rf pm-default-alias" &&
> + git clone pm-upstream pm-default-alias &&
> + git -C pm-default-alias branch --track trunk origin/main &&
> + git -C pm-default-alias checkout --detach &&
> + git -C pm-default-alias branch --prune-merged origin &&
> + git -C pm-default-alias rev-parse --verify refs/heads/main &&
> + test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
> +'
> +
> test_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe 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
A couple more thoughts ...
On 18/05/2026 16:27, Phillip Wood wrote:
> On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
>> From: Harald Nordgren <haraldnordgren@gmail.com>
>>
>> Delete the local branches that --forked <remote> would list, but
>> only those whose tip is reachable from their configured upstream
>> remote-tracking branch (branch.<name>.merge): the work has already
>> landed on the upstream it tracks, so the local copy is no longer
>> needed.
While we want to clean up topic branches, we want to avoid cleaning up branches like "master" which follow an upstream branch and therefore look like they've been merged straight after they've been pulled. So I think as well as checking that the local branch is merged into its upstream branch, we want to check that the local branch is not pushed to the upstream branch i.e. that branch@{upstream} != branch@{push}. That should also avoid deleting newly created topic branches that match their upstream (I think that's probably less likely to happen in practice as I'd expect the branch to be checked out and therefore protected against deletion).
Also as this is a destructive operation (there is no way to restore a deleted branch and its reflog) it would be good to have a --dry-run option.
Thanks
PhillipThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harald Nordgren wrote on the Git mailing list (how to reply to this email): > I think being able to prune branches that have been merged into their
> upstream is a good idea. However, I find the focus on remotes rather
> than upstream branches in the UI a bit confusing. While the upstream of
> a branch is often a remote tracking branch it doesn't have to be. For my
> personal projects I often do
>
> git checkout -b topic master
>
> and it would be nice to be able to run
>
> git branch --prune-merged master
>
> to clean up those topics that have been merged. Similarly I think it is
> confusing that
>
> git checkout -b topic origin
>
> starts a branch from the default branch on origin, but if I run
>
> git branch --prune-merged origin
>
> to clean it up, it will clean up all the branches with an upstream on
> origin, not just those whose upstream matches origin/HEAD.
>
> So I like the idea, but would prefer the arguments to --prune-merged to
> be upstream branches, not remotes. We could support globs so that
>
> git branch --prune-merges 'origin/*'
>
> would clean up all the branches whose upstream is on origin if that is
> useful.
>
> Thanks
>
> Phillip
Hi Phillip!
This seems like a big change. It almost becomes a different feature.
Would be interesting to hear what others have to say as well.
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): Phillip Wood <phillip.wood123@gmail.com> writes:
> I think being able to prune branches that have been merged into their
> upstream is a good idea. However, I find the focus on remotes rather
> than upstream branches in the UI a bit confusing. While the upstream of
> a branch is often a remote tracking branch it doesn't have to be. For my
> personal projects I often do
>
> git checkout -b topic master
>
> and it would be nice to be able to run
>
> git branch --prune-merged master
>
> to clean up those topics that have been merged.
Excellent suggestion.
The task at hand is to make a list of things that "track" (in the
"checkout -t" sense) something, see which one in that list are
descendant of that something, and removing those that have already
been merged into that something. There is nothing that requires
that something to be a remote-tracking branch at all.
If
git branch --forked master
(i.e., "who tracks 'master' in the 'checkout -t' sense") were
available, then
git branch --merged master | sort >1
git branch --forked master | sort >2
comm -12 1 2
would give us the list of branches that forked from 'master' and
have already been merged, i.e., candidate to be removed via the
"prune-merged" mechanism. With the built-in "git branch -d"
protection to require capital "-D" to force removal of a new work
that is not merged to tracked, we actually need only
git branch --forked master |
xargs git branch -d
to do so. We may want to give --dry-run to "git branch -d" so that
you can say
git branch --forked master |
xargs git branch -d --dry-run
to see what would be removed.
And again, there is no strong reason why "--forked" has to work only
with remote-tracking branches.
> Similarly I think it is
> confusing that
>
> git checkout -b topic origin
>
> starts a branch from the default branch on origin, but if I run
>
> git branch --prune-merged origin
Sorry, this was my bad. I agree that it is not a useful thing for
"origin" to mean "origin/*" here when it is established that
"origin" to mean "origin/HEAD".There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): > While we want to clean up topic branches, we want to avoid cleaning up
> branches like "master" which follow an upstream branch and therefore
> look like they've been merged straight after they've been pulled. So I
> think as well as checking that the local branch is merged into its
> upstream branch, we want to check that the local branch is not pushed to
> the upstream branch i.e. that branch@{upstream} != branch@{push}.
This one I handle already by letting the default branch be guarded.
Harald
On Thu, May 21, 2026 at 11:46 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Harald
>
> A couple more thoughts ...
>
> On 18/05/2026 16:27, Phillip Wood wrote:
> > On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
> >> From: Harald Nordgren <haraldnordgren@gmail.com>
> >>
> >> Delete the local branches that --forked <remote> would list, but
> >> only those whose tip is reachable from their configured upstream
> >> remote-tracking branch (branch.<name>.merge): the work has already
> >> landed on the upstream it tracks, so the local copy is no longer
> >> needed.
>
> While we want to clean up topic branches, we want to avoid cleaning up
> branches like "master" which follow an upstream branch and therefore
> look like they've been merged straight after they've been pulled. So I
> think as well as checking that the local branch is merged into its
> upstream branch, we want to check that the local branch is not pushed to
> the upstream branch i.e. that branch@{upstream} != branch@{push}. That
> should also avoid deleting newly created topic branches that match their
> upstream (I think that's probably less likely to happen in practice as
> I'd expect the branch to be checked out and therefore protected against
> deletion).
>
> Also as this is a destructive operation (there is no way to restore a
> deleted branch and its reflog) it would be good to have a --dry-run option.
>
> Thanks
>
> Phillip
>There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 21/05/2026 20:16, Harald Nordgren wrote:
>> While we want to clean up topic branches, we want to avoid cleaning up
>> branches like "master" which follow an upstream branch and therefore
>> look like they've been merged straight after they've been pulled. So I
>> think as well as checking that the local branch is merged into its
>> upstream branch, we want to check that the local branch is not pushed to
>> the upstream branch i.e. that branch@{upstream} != branch@{push}.
> > This one I handle already by letting the default branch be guarded.
I used "master" as an example of a branch name above. There is no guarantee that a remote even defines a default branch, let alone that there is only one local branch where the its upstream and push destinations match. I don't see how you can avoid checking that the branch pushes to a different ref than its upstream and still be safe.
I'm going to be off the list from now until the week after next, I'll catch up with this thread when I'm back on line.
Thanks
Phillip
> > Harald
> > On Thu, May 21, 2026 at 11:46 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>>
>> Hi Harald
>>
>> A couple more thoughts ...
>>
>> On 18/05/2026 16:27, Phillip Wood wrote:
>>> On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:
>>>> From: Harald Nordgren <haraldnordgren@gmail.com>
>>>>
>>>> Delete the local branches that --forked <remote> would list, but
>>>> only those whose tip is reachable from their configured upstream
>>>> remote-tracking branch (branch.<name>.merge): the work has already
>>>> landed on the upstream it tracks, so the local copy is no longer
>>>> needed.
>>
>> While we want to clean up topic branches, we want to avoid cleaning up
>> branches like "master" which follow an upstream branch and therefore
>> look like they've been merged straight after they've been pulled. So I
>> think as well as checking that the local branch is merged into its
>> upstream branch, we want to check that the local branch is not pushed to
>> the upstream branch i.e. that branch@{upstream} != branch@{push}. That
>> should also avoid deleting newly created topic branches that match their
>> upstream (I think that's probably less likely to happen in practice as
>> I'd expect the branch to be checked out and therefore protected against
>> deletion).
>>
>> Also as this is a destructive operation (there is no way to restore a
>> deleted branch and its reflog) it would be good to have a --dry-run option.
>>
>> Thanks
>>
>> Phillip
>>
> There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): > >> While we want to clean up topic branches, we want to avoid cleaning up
> >> branches like "master" which follow an upstream branch and therefore
> >> look like they've been merged straight after they've been pulled. So I
> >> think as well as checking that the local branch is merged into its
> >> upstream branch, we want to check that the local branch is not pushed to
> >> the upstream branch i.e. that branch@{upstream} != branch@{push}.
> >
> > This one I handle already by letting the default branch be guarded.
>
> I used "master" as an example of a branch name above. There is no
> guarantee that a remote even defines a default branch, let alone that
> there is only one local branch where the its upstream and push
> destinations match. I don't see how you can avoid checking that the
> branch pushes to a different ref than its upstream and still be safe.
Ok, I'll give it a shot!
> I'm going to be off the list from now until the week after next, I'll
> catch up with this thread when I'm back on line.
Enjoy the time off!
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> +`--prune-merged`::
> + Delete the local branches that `--forked` would list for
> + the same _<branch>_ arguments, but only those whose tip is
> + reachable from their configured upstream.
> ++
> +For arguments that refer to remote-tracking branches, run
> +`git fetch` first ...
Please don't.
"git branch -d <derived>" checks if <derived> has already been
merged to its <upstream> as your repository currently sees it, and
this makes "--prune-merged" inconsistent. Before deciding to use
"--prune-merged", the user may have sanity checked if it is safe to
remove by running "git branch -no-merged", whose answer hence user's
sanity checking will be invalidated by your auto-update from the
upstream.
It also means that you cannot prune already merged ones while you
are not online.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1e24c95a69..29d38e9060 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
Due to the way the patch is split between 1/4 and 2/4, it is
impossible to comment on the change to delete_branches etc. that are
needed for this step. I'll use "git diff master... builtin/" instead.
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..b89fd56112 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -1,43 +1,47 @@
> ...
> @@ -132,78 +136,87 @@ static const char *branch_get_color(enum color_branch ix)
> static int branch_merged(int kind, const char *name,
> struct commit *rev, struct commit *head_rev)
> {
This one is called from check_branch_commit(). In "--prune-merged"
code paths, as we will see below, kind==FILTER_REFS_BRANCHES is
passed.
But note that there is no reason to expect "--prune-merged" will be
the only one to pass FILTER_REFS_BRANCHES to this function. Most
notably, "git branch -d" without "-r" would set FILTER_REFS_BRANCHES
to filter.kind and passes the value here.
> /*
> * This checks whether the merge bases of branch and HEAD (or
> * the other branch this branch builds upon) contains the
> * branch, which means that the branch has already been merged
> * safely to HEAD (or the other branch).
> */
> struct commit *reference_rev = NULL;
> const char *reference_name = NULL;
> void *reference_name_to_free = NULL;
> int merged;
>
> if (kind == FILTER_REFS_BRANCHES) {
> struct branch *branch = branch_get(name);
> const char *upstream = branch_get_upstream(branch, NULL);
> struct object_id oid;
>
> if (upstream &&
> (reference_name = reference_name_to_free =
> refs_resolve_refdup(get_main_ref_store(the_repository), upstream, RESOLVE_REF_READING,
> &oid, NULL)) != NULL)
> reference_rev = lookup_commit_reference(the_repository,
> &oid);
> }
We find its upstream in reference_rev; if this is non-NULL, the
branch MUST be merged to that revision for it to be safely removed.
> if (!reference_rev)
> reference_rev = head_rev;
But when ehad_rev is given, and if there is no upstream, we check if
the branch is merged to whatever happens to be checked out instead.
For the purpose of "--prune-merged", therefore, we MUST pass NULL in
head_rev when we call this function. We'll see what is done in the
caller later.
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1e24c95a69..29d38e9060 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,
> * any of the following code, but during the transition period,
> * a gentle reminder is in order.
> */
> - if (head_rev != reference_rev) {
> - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> + if (head_rev && head_rev != reference_rev) {
> + int expect = repo_in_merge_bases(the_repository, rev, head_rev);
Any caller who passed head_rev==NULL still used to come into this
block, set expect to 0, and have it compared with merged. If merged
is 0 (which is what happens when reference_rev is NULL and head_rev
is NULL), the control left this block silently due to /* okay */
below (which is in the post-context that is not shown).
The updated code refuses control to come into this block when
head_rev is NULL. I am not sure why the change in this hunk is
needed.
> @@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
> return 0;
> }
>
> +static int collect_default_branch_name(struct remote *remote, void *cb_data)
> +{
> + struct string_list *protected = cb_data;
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct strbuf head = STRBUF_INIT;
> + const char *target;
> +
> + strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
> + target = refs_resolve_ref_unsafe(refs, head.buf,
> + RESOLVE_REF_NO_RECURSE, NULL, NULL);
> + if (target) {
> + const char *leaf = strrchr(target, '/');
> + if (leaf)
> + string_list_insert(protected, leaf + 1);
> + }
> + strbuf_release(&head);
> + return 0;
> +}
It is strange to assume that whatever upstream repository happens to
use as its primary branch name has anything to do with how the local
repository names its primary branch. Shouldn't this instead use
init.defaultBranch configuration or something?
> @@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)
> 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 string_list candidates = STRING_LIST_INIT_DUP;
> + struct string_list protected_default_names = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct strbuf buf = STRBUF_INIT;
> + struct string_list_item *item;
> + int n_not_merged = 0;
> + int ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(argc, argv, &candidates);
Due to poor separation of changes, all the necessary information to
assess how sane the above code is is hidden in [1/4] and not here.
> + for_each_remote(collect_default_branch_name, &protected_default_names);
This looks inefficient, and worse, incorrect.
If we have multiple branches in argv[], they may have come from
different upstream, and because you pay attention to what the
refs/remotes/<upstream>/HEAD symrefs point at, you have multiple
such "protected" default names. You'd compare each and every
candidates against these names using linear search in the
string_list to see if they are protected (inefficient), and you
reject removal of argv[1] even when it happens to be the same name
as the default in the repository from which the upstream of argv[3]
came from, i.e., has no relationship with argv[1] (incorrect).
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + const char *upstream = item->util;
> +
> + strbuf_reset(&buf);
> + strbuf_addf(&buf, "refs/heads/%s", short_name);
> + if (branch_checked_out(buf.buf))
> + continue;
> +
> + if (string_list_has_string(&protected_default_names,
> + short_name))
> + continue;
> +
> + if (!refs_ref_exists(refs, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> + strbuf_release(&buf);
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 0, FILTER_REFS_BRANCHES, quiet,
> + 1, &n_not_merged);
Add comments on parameters, perhaps, to make it readable?
ret = delete_branches(deletable.nr, deletable.v,
0, /* no force */
FILTER_REFS_BRANCHES,
quiet,
1, /* warn only */
&n_not_merged);
or something?
Unfortunately the body of delete_branches() updated by this series
is not visible in this patch, making a sensible review impossible,
but if I recall correctly from what I saw in [1/4], with no-force
set, it does not leave head_rev NULL, and instead reads the HEAD
into it, and that is eventually passed to check_branch_commit()
call.
Now, check_branch_commit() passes head_rev to branch_merged(), which
falls back to "HEAD" when head_rev is given. That sounds incorrect
for at least two reasons. We are only dealing with branches that do
have upstream, so fallback should not trigger and we shouldn't be
allowing head_rev to be passed. It _might_ be debatable that it is
prudent to still expect branch_merged() not to find the upstream to
detect errors in the logic, but falling back to head_rev in such a
case does not make any sense.
> + if (n_not_merged && !quiet)
> + fprintf(stderr,
> + Q_("Skipped %d branch that is not fully merged; "
> + "delete it with 'git branch -D' if you are sure.\n",
> + "Skipped %d branches that are not fully merged; "
> + "delete them with 'git branch -D' if you are sure.\n",
> + n_not_merged),
> + n_not_merged);
I do not think we unconditionally want to see this, and "--quiet"
shouldn't be the onlyl way to squelch this message.
When !quiet, the warn_only call to check_branch_commit() would
already have reported which branches are not fully merged, and
after seeing this message a few times, even the most novice user
would know how to use "git branch -D" to remove unneeded branches.
Use of advice_if_enabled() may make it more palatable.
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 45455cb8ce..c8589cd3a6 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1798,4 +1798,143 @@ test_expect_success '--forked requires at least one <branch>' '
> test_grep "at least one <branch>" 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_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 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 with a literal upstream argument' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> + git -C pm-literal branch keepme one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/main keepme &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
> + git -C pm-literal rev-parse --verify refs/heads/keepme
> +'
> +
> +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 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 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 with a local-branch argument' '
> + test_create_repo pm-local &&
> + test_when_finished "rm -rf pm-local" &&
> + test_commit -C pm-local base &&
> + git -C pm-local branch topic base &&
> + git -C pm-local config branch.topic.remote . &&
> + git -C pm-local config branch.topic.merge refs/heads/main &&
> + git -C pm-local checkout --detach &&
> +
> + git -C pm-local branch --prune-merged main &&
> +
> + test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
> + git -C pm-local rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares branches with un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + 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 "Skipped 1 branch" err &&
> + test_grep "git branch -D" 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 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 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 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 the local default branch' '
> + test_when_finished "rm -rf pm-default" &&
> + git clone pm-upstream pm-default &&
> + git -C pm-default checkout --detach &&
> + git -C pm-default branch --prune-merged "origin/*" &&
> + git -C pm-default rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged protects the default branch by name only' '
> + test_when_finished "rm -rf pm-default-alias" &&
> + git clone pm-upstream pm-default-alias &&
> + git -C pm-default-alias branch --track trunk origin/main &&
> + git -C pm-default-alias checkout --detach &&
> + git -C pm-default-alias branch --prune-merged "origin/*" &&
> + git -C pm-default-alias rev-parse --verify refs/heads/main &&
> + test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
> +'
> +
> +test_expect_success '--prune-merged with literal arg also protects default-name' '
> + test_when_finished "rm -rf pm-literal-default" &&
> + git clone pm-upstream pm-literal-default &&
> + git -C pm-literal-default checkout --detach &&
> + git -C pm-literal-default branch --prune-merged origin/main &&
> + git -C pm-literal-default rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> + test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
> + test_grep "at least one <branch>" err
> +'
> +
> test_doneThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): Junio C Hamano <gitster@pobox.com> writes:
> "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
>
>
>
>> diff --git a/builtin/branch.c b/builtin/branch.c
>> index 1e24c95a69..29d38e9060 100644
>> --- a/builtin/branch.c
>> +++ b/builtin/branch.c
>
> Due to the way the patch is split between 1/4 and 2/4, it is
> impossible to comment on the change to delete_branches etc. that are
> needed for this step. I'll use "git diff master... builtin/" instead.
>
Please discard this version. I had unnecessary draft comments that
I used as reference in it.There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): > Please discard this version. I had unnecessary draft comments that
> I used as reference in it.
I'm taking this to mean starting over from v9 and implementing the
'origin/*' idea again from there. Correct?
HaraldThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): Harald Nordgren <haraldnordgren@gmail.com> writes:
>> Please discard this version. I had unnecessary draft comments that
>> I used as reference in it.
>
> I'm taking this to mean starting over from v9 and implementing the
> 'origin/*' idea again from there. Correct?
No, what I meant was "please discard the review message I was
responding to".
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1e24c95a69..29d38e9060 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,
> * any of the following code, but during the transition period,
> * a gentle reminder is in order.
> */
> - if (head_rev != reference_rev) {
> - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> + if (head_rev && head_rev != reference_rev) {
> + int expect = repo_in_merge_bases(the_repository, rev, head_rev);
Any caller who passed head_rev==NULL still used to come into this
block, set expect to 0, and have it compared with merged. If merged
is 0 (which is what happens when reference_rev is NULL and head_rev
is NULL), the control left this block silently due to /* okay */
below (which is in the post-context that is not shown).
The updated code refuses control to come into this block when
head_rev is NULL. I am not sure why the change in this hunk is
needed.
> @@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
> return 0;
> }
>
> +static int collect_default_branch_name(struct remote *remote, void *cb_data)
> +{
> + struct string_list *protected = cb_data;
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct strbuf head = STRBUF_INIT;
> + const char *target;
> +
> + strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
> + target = refs_resolve_ref_unsafe(refs, head.buf,
> + RESOLVE_REF_NO_RECURSE, NULL, NULL);
> + if (target) {
> + const char *leaf = strrchr(target, '/');
> + if (leaf)
> + string_list_insert(protected, leaf + 1);
> + }
> + strbuf_release(&head);
> + return 0;
> +}
It is strange to assume that whatever upstream repository happens to
use as its primary branch name has anything to do with how the local
repository names its primary branch. Shouldn't this instead use
init.defaultBranch configuration or something?
> @@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)
> 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 string_list candidates = STRING_LIST_INIT_DUP;
> + struct string_list protected_default_names = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct strbuf buf = STRBUF_INIT;
> + struct string_list_item *item;
> + int n_not_merged = 0;
> + int ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(argc, argv, &candidates);
Due to poor separation of changes, all the necessary information to
assess how sane the above code is is hidden in [1/4] and not here.
> + for_each_remote(collect_default_branch_name, &protected_default_names);
This looks inefficient, and worse, incorrect.
If we have multiple branches in argv[], they may have come from
different upstream, and because you pay attention to what the
refs/remotes/<upstream>/HEAD symrefs point at, you have multiple
such "protected" default names. You'd compare each and every
candidates against these names using linear search in the
string_list to see if they are protected (inefficient), and you
reject removal of argv[1] even when it happens to be the same name
as the default in the repository from which the upstream of argv[3]
came from, i.e., has no relationship with argv[1] (incorrect).
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + const char *upstream = item->util;
> +
> + strbuf_reset(&buf);
> + strbuf_addf(&buf, "refs/heads/%s", short_name);
> + if (branch_checked_out(buf.buf))
> + continue;
> +
> + if (string_list_has_string(&protected_default_names,
> + short_name))
> + continue;
> +
> + if (!refs_ref_exists(refs, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> + strbuf_release(&buf);
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 0, FILTER_REFS_BRANCHES, quiet,
> + 1, &n_not_merged);
Add comments on parameters, perhaps, to make it readable?
ret = delete_branches(deletable.nr, deletable.v,
0, /* no force */
FILTER_REFS_BRANCHES,
quiet,
1, /* warn only */
&n_not_merged);
or something?
Unfortunately the body of delete_branches() updated by this series
is not visible in this patch, making a sensible review impossible,
but if I recall correctly from what I saw in [1/4], with no-force
set, it does not leave head_rev NULL, and instead reads the HEAD
into it, and that is eventually passed to check_branch_commit()
call.
Now, check_branch_commit() passes head_rev to branch_merged(), which
falls back to "HEAD" when head_rev is given. That sounds incorrect
for at least two reasons. We are only dealing with branches that do
have upstream, so fallback should not trigger and we shouldn't be
allowing head_rev to be passed. It _might_ be debatable that it is
prudent to still expect branch_merged() not to find the upstream to
detect errors in the logic, but falling back to head_rev in such a
case does not make any sense.
> + if (n_not_merged && !quiet)
> + fprintf(stderr,
> + Q_("Skipped %d branch that is not fully merged; "
> + "delete it with 'git branch -D' if you are sure.\n",
> + "Skipped %d branches that are not fully merged; "
> + "delete them with 'git branch -D' if you are sure.\n",
> + n_not_merged),
> + n_not_merged);
I do not think we unconditionally want to see this, and "--quiet"
shouldn't be the onlyl way to squelch this message.
When !quiet, the warn_only call to check_branch_commit() would
already have reported which branches are not fully merged, and
after seeing this message a few times, even the most novice user
would know how to use "git branch -D" to remove unneeded branches.
Use of advice_if_enabled() may make it more palatable. |
||
| git branch --edit-description [<branch-name>] | ||
| git branch [--dry-run] --delete-merged <branch>... | ||
|
|
||
| DESCRIPTION | ||
| ----------- | ||
|
|
@@ -51,7 +53,8 @@ merged into the named commit (i.e. the branches whose tip commits are | |
| reachable from the named commit) will be listed. With `--no-merged` only | ||
| branches not merged into the named commit will be listed. If the _<commit>_ | ||
| argument is missing it defaults to `HEAD` (i.e. the tip of the current | ||
| branch). | ||
| branch). With `--forked`, only branches whose configured upstream matches | ||
| the given branch or pattern will be listed. | ||
|
|
||
| The command's second form creates a new branch head named _<branch-name>_ | ||
| which points to the current `HEAD`, or _<start-point>_ if given. As a | ||
|
|
@@ -199,6 +202,41 @@ This option is only applicable in non-verbose mode. | |
| Print the name of the current branch. In detached `HEAD` state, | ||
| nothing is printed. | ||
|
|
||
| `--delete-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 | ||
| --delete-merged origin/main 'feature*'`. | ||
| + | ||
| A branch is not deleted when: | ||
| + | ||
| -- | ||
| * its upstream remote-tracking branch no longer exists, | ||
| * it is checked out in any worktree, | ||
| * its push destination (`<branch>@{push}`) equals its upstream | ||
| (`<branch>@{upstream}`), so it cannot be distinguished from a | ||
| branch that just looks "fully merged" right after a pull, or | ||
| * `branch.<name>.deleteMerged` is set to `false`. | ||
| -- | ||
| + | ||
| A branch whose work has not yet been merged into its upstream is | ||
| silently skipped. Delete it with `git branch -D` if you want to | ||
| remove it anyway. | ||
| + | ||
| A branch that another, surviving branch tracks as its upstream is | ||
| kept, so a branch is never deleted out from under one stacked on top | ||
| of it. If that kept branch in turn tracks a branch that is being | ||
| deleted, its now-stale upstream configuration is cleared. | ||
|
|
||
| `--dry-run`:: | ||
| With `--delete-merged`, print which branches would be | ||
| deleted and exit without touching any ref. Useful for | ||
| sanity-checking a wide pattern like `'origin/*'` before | ||
| committing to the deletion. | ||
|
|
||
| `-v`:: | ||
| `-vv`:: | ||
| `--verbose`:: | ||
|
|
@@ -311,6 +349,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". | |
| Only list branches whose tips are not reachable from | ||
| _<commit>_ (`HEAD` if not specified). Implies `--list`. | ||
|
|
||
| `--forked <branch>`:: | ||
| Only list 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-style glob (e.g. | ||
| `'origin/*'`). The option can be repeated to widen the | ||
| filter. Implies `--list`. | ||
|
|
||
| `--points-at <object>`:: | ||
| Only list branches of _<object>_. | ||
|
|
||
|
|
||

There was a problem hiding this comment.
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):