From b201bdb32d1a7074924c9c61d0be4cba36a01620 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:34 +0000 Subject: [PATCH 1/6] branch: add --forked filter for --list mode Add a --forked option to "git branch" list mode that keeps only branches whose configured upstream matches . 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. 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. 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 Signed-off-by: Junio C Hamano --- 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[=] | --no-color] [--show-current] [--merged []] [--no-merged []] [--contains []] [--no-contains []] [--points-at ] [--format=] + [(--forked )...] [(-r|--remotes) | (-a|--all)] [--list] [...] 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 `:: + List only branches whose configured upstream matches + __. 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. + `-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 [] [-r | -a] [--merged] [--no-merged]"), + N_("git branch [] [-r | -a] [--merged] [--no-merged] [(--forked )...]"), N_("git branch [] [-f] [--recurse-submodules] []"), N_("git branch [] [-l] [...]"), N_("git branch [] [-r] (-d | -D) ..."), @@ -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); + 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); + if (filter->verbose) maxwidth = calc_maxwidth(&array, strlen(remote_prefix)); @@ -673,6 +681,131 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int free_worktrees(worktrees); } +struct upstream_pattern { + char *name; + int is_wildcard; +}; + +static void upstream_pattern_list_clear(struct upstream_pattern *items, + size_t nr) +{ + size_t i; + for (i = 0; i < nr; i++) + free(items[i].name); + free(items); +} + +static const char *short_upstream_name(const char *full_ref) +{ + const char *short_name = full_ref; + (void)(skip_prefix(short_name, "refs/heads/", &short_name) || + skip_prefix(short_name, "refs/remotes/", &short_name)); + return short_name; +} + +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)); + out->is_wildcard = 0; + free(full_ref); + return 0; + } + free(full_ref); + return -1; +} + +static void parse_forked_args(const struct string_list *args, + struct upstream_pattern **patterns_out, + size_t *nr_out) +{ + struct upstream_pattern *patterns; + size_t i; + + ALLOC_ARRAY(patterns, args->nr); + for (i = 0; i < args->nr; i++) { + const char *arg = args->items[i].string; + if (parse_one_forked_arg(arg, &patterns[i]) < 0) { + upstream_pattern_list_clear(patterns, i); + die(_("'%s' is not a valid branch or pattern"), arg); + } + } + *patterns_out = patterns; + *nr_out = args->nr; +} + +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; + } + } + return 0; +} + +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), + patterns, nr_patterns); +} + +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; + + upstream_pattern_list_clear(patterns, nr_patterns); +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -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; 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 (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 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 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 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 From 0ce3d598de4b08d8af30b41f07eded459d6c164b Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:35 +0000 Subject: [PATCH 2/6] branch: let delete_branches warn instead of error on bulk refusal Add a warn_only flag to delete_branches() and check_branch_commit() so a bulk caller can report not-fully-merged branches as one-line warnings and continue, instead of erroring with the four-line "use 'git branch -D'" advice that the standalone "git branch -d" path emits. Default callers pass 0 and are unaffected. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index 12711b29cf..93d8eae891 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -192,7 +192,7 @@ 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) { struct commit *rev = lookup_commit_reference(the_repository, oid); if (!force && !rev) { @@ -200,10 +200,16 @@ 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); + } return -1; } return 0; @@ -219,7 +225,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) { struct commit *head_rev = NULL; struct object_id oid; @@ -309,8 +315,9 @@ 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)) { - ret = 1; + force, warn_only)) { + if (!warn_only) + ret = 1; goto next; } @@ -995,7 +1002,8 @@ 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); goto out; } else if (show_current) { print_current_branch_name(); From 4d70f2d269fe6f0e7aa580fa8746f5fb9fa857d1 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:36 +0000 Subject: [PATCH 3/6] branch: prepare delete_branches for a bulk caller Add no_head_fallback and dry_run flags to delete_branches() so a bulk caller (the upcoming --prune-merged) can ask strictly about merged-into-upstream without a silent fallback to HEAD, and rehearse deletions with the same "Would delete branch ..." wording as the live run. Existing callers pass 0 for both and keep current behavior. When no_head_fallback is set, head_rev stays NULL through to branch_merged(), whose "merged to X but not yet merged to HEAD" reminder otherwise compares against HEAD. For the bulk caller every candidate is known to have an upstream, so HEAD is irrelevant. Guard the block on head_rev so the NULL case skips it instead of treating "NULL != reference_rev" as "diverges from HEAD" and emitting a spurious warning. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index 93d8eae891..09afdd9257 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -169,10 +169,13 @@ static int branch_merged(int kind, const char *name, * upstream, if any, otherwise with HEAD", we should just * return the result of the repo_in_merge_bases() above without * any of the following code, but during the transition period, - * a gentle reminder is in order. + * a gentle reminder is in order. Callers that opt out of the + * HEAD fallback by passing head_rev=NULL are not interested in + * the reminder either: they have already established that the + * branch has an upstream, so HEAD is irrelevant to the decision. */ - 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) @@ -225,7 +228,8 @@ static void delete_branch_config(const char *branchname) } static int delete_branches(int argc, const char **argv, int force, int kinds, - int quiet, int warn_only) + int quiet, int warn_only, int no_head_fallback, + int dry_run) { struct commit *head_rev = NULL; struct object_id oid; @@ -259,7 +263,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)) { @@ -330,13 +334,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, free(target); } - if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) + if (!dry_run && + refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) ret = 1; for_each_string_list_item(item, &refs_to_delete) { char *describe_ref = item->util; char *name = item->string; - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) { + if (dry_run) { + if (!quiet) + printf(remote_branch + ? _("Would delete remote-tracking branch %s (was %s).\n") + : _("Would delete branch %s (was %s).\n"), + name + branch_name_pos, describe_ref); + } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) { char *refname = name + branch_name_pos; if (!quiet) printf(remote_branch @@ -1003,7 +1014,7 @@ int cmd_branch(int argc, if (!argc) die(_("branch name required")); ret = delete_branches(argc, argv, delete > 1, filter.kind, - quiet, 0); + quiet, 0, 0, 0); goto out; } else if (show_current) { print_current_branch_name(); From c983bf820300036de86e5f2b228bbf7a484a6747 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:37 +0000 Subject: [PATCH 4/6] branch: add --prune-merged git branch --prune-merged ... deletes the local branches that "--forked " 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 (@{push} == @{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 Signed-off-by: Junio C Hamano --- 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) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch (--prune-merged )... 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 `:: + Delete the local branches that `--forked` would list for the + same __, 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 (`@{push}`) equals its +upstream (`@{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 [] (-c | -C) [] "), N_("git branch [] [-r | -a] [--points-at]"), N_("git branch [] [-r | -a] [--format]"), + N_("git branch [] (--prune-merged )..."), 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 ")); + + 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 (repeatable)")), + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"), + N_("delete local branches whose upstream matches 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 ")); + 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 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 From 3f148f0eb1f24cb110ba441149b8002e1ef20bbe Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:38 +0000 Subject: [PATCH 5/6] branch: add branch..pruneMerged opt-out Setting branch..pruneMerged=false exempts that branch from "git branch --prune-merged". Useful for a topic branch you want to develop further after an initial round has been merged upstream. Unless --quiet is given, the skip is reported per branch so the user knows why their topic was preserved. Explicit deletion via "git branch -d" continues to consult the normal merge check and is not affected by this setting. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 14 ++++++++++++++ t/t3200-branch.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc index a4db9fa5c8..6c1b5bb9cd 100644 --- a/Documentation/config/branch.adoc +++ b/Documentation/config/branch.adoc @@ -102,3 +102,10 @@ for details). `git branch --edit-description`. Branch description is automatically added to the `format-patch` cover letter or `request-pull` summary. + +`branch..pruneMerged`:: + If set to `false`, branch __ is exempt from + `git branch --prune-merged`. Useful for a topic branch you + intend to develop further after an initial round has been + merged upstream. Defaults to true. Explicit deletion via + `git branch -d` is unaffected. diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index f7942fcd7d..69878549fc 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -221,9 +221,10 @@ 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 (`@{push}`) equals its +worktree; its push destination (`@{push}`) equals its upstream (`@{upstream}`), so it cannot be distinguished -from a freshly pulled trunk that just looks "fully merged". +from a freshly pulled trunk that just looks "fully merged"; or +`branch..pruneMerged` is set to `false`. + Branches refused by the "fully merged" safety check are listed as warnings and skipped; pass them to `git branch -D` explicitly if diff --git a/builtin/branch.c b/builtin/branch.c index 736480b002..e03805a8a7 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -878,7 +878,9 @@ static int prune_merged_branches(const struct string_list *upstreams, struct branch *branch = branch_get(short_name); const char *upstream, *push; struct strbuf full = STRBUF_INIT; + struct strbuf key = STRBUF_INIT; int skip; + int opt_out; strbuf_addf(&full, "refs/heads/%s", short_name); skip = !!branch_checked_out(full.buf); @@ -893,6 +895,18 @@ static int prune_merged_branches(const struct string_list *upstreams, if (!push || !strcmp(push, upstream)) continue; + strbuf_addf(&key, "branch.%s.prunemerged", short_name); + if (!repo_config_get_bool(the_repository, key.buf, &opt_out) && + !opt_out) { + if (!quiet) + fprintf(stderr, + _("Skipping '%s' (branch.%s.pruneMerged is false)\n"), + short_name, short_name); + strbuf_release(&key); + continue; + } + strbuf_release(&key); + strvec_push(&deletable, short_name); } diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index beb86987ad..9e33179590 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1997,4 +1997,34 @@ test_expect_success '--prune-merged rejects positional arguments' ' test_grep "does not take positional arguments" err ' +test_expect_success '--prune-merged honours branch..pruneMerged=false' ' + test_when_finished "rm -rf pm-optout" && + git clone pm-upstream pm-optout && + git -C pm-optout remote add fork ../pm-fork && + test_config -C pm-optout remote.pushDefault fork && + test_config -C pm-optout push.default current && + git -C pm-optout branch one one-commit && + git -C pm-optout branch --set-upstream-to=origin/next one && + git -C pm-optout branch two two-commit && + git -C pm-optout branch --set-upstream-to=origin/next two && + test_config -C pm-optout branch.one.pruneMerged false && + + git -C pm-optout branch --prune-merged "origin/*" 2>err && + + git -C pm-optout rev-parse --verify refs/heads/one && + test_must_fail git -C pm-optout rev-parse --verify refs/heads/two && + test_grep "Skipping .one." err +' + +test_expect_success 'branch -d still deletes a pruneMerged=false branch' ' + test_when_finished "rm -rf pm-optout-d" && + git clone pm-upstream pm-optout-d && + git -C pm-optout-d branch one one-commit && + git -C pm-optout-d branch --set-upstream-to=origin/next one && + test_config -C pm-optout-d branch.one.pruneMerged false && + + git -C pm-optout-d branch -d one && + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one +' + test_done From 972e17e2f43659280e5bf368f4f3f2ec7dfe110f Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 3 Jun 2026 09:04:39 +0000 Subject: [PATCH 6/6] branch: add --dry-run for --prune-merged With --dry-run, --prune-merged prints the local branches it would delete, one "Would delete branch " line per candidate, and exits without touching any ref. The @{push}-vs-@{upstream} and unmerged filtering still applies, so the dry-run output is exactly the set that the live run would delete. --dry-run is only meaningful in combination with --prune-merged and is rejected otherwise. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 8 ++++++- builtin/branch.c | 12 +++++++--- t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index 69878549fc..c579df4fe0 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,7 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] -git branch (--prune-merged )... +git branch [--dry-run] (--prune-merged )... DESCRIPTION ----------- @@ -230,6 +230,12 @@ 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. +`--dry-run`:: + With `--prune-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`:: diff --git a/builtin/branch.c b/builtin/branch.c index e03805a8a7..1811511b9e 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -860,7 +860,7 @@ static void collect_forked_set(const struct string_list *upstreams, } static int prune_merged_branches(const struct string_list *upstreams, - int quiet) + int quiet, int dry_run) { struct ref_store *refs = get_main_ref_store(the_repository); struct string_list candidates = STRING_LIST_INIT_DUP; @@ -917,7 +917,7 @@ static int prune_merged_branches(const struct string_list *upstreams, quiet, 1, /* warn_only */ 1, /* no_head_fallback */ - 0 /* dry_run */); + dry_run); strvec_clear(&deletable); string_list_clear(&candidates, 0); @@ -967,6 +967,7 @@ int cmd_branch(int argc, 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; + int dry_run = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -1024,6 +1025,8 @@ int cmd_branch(int argc, N_("list local branches whose upstream matches (repeatable)")), OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"), N_("delete local branches whose upstream matches and is merged (repeatable)")), + OPT_BOOL(0, "dry-run", &dry_run, + N_("with --prune-merged, only print which branches would be deleted")), 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")), @@ -1083,6 +1086,9 @@ int cmd_branch(int argc, if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); + if (dry_run && !prune_merged_upstreams.nr) + die(_("--dry-run requires --prune-merged")); + if (recurse_submodules_explicit) { if (!submodule_propagate_branches) die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled")); @@ -1124,7 +1130,7 @@ int cmd_branch(int argc, if (argc) die(_("--prune-merged does not take positional arguments; " "repeat --prune-merged for each ")); - ret = prune_merged_branches(&prune_merged_upstreams, quiet); + ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run); goto out; } else if (show_current) { print_current_branch_name(); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 9e33179590..29bfd0e109 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -2027,4 +2027,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' ' + test_when_finished "rm -rf pm-dry" && + git clone pm-upstream pm-dry && + git -C pm-dry remote add fork ../pm-fork && + test_config -C pm-dry remote.pushDefault fork && + test_config -C pm-dry push.default current && + git -C pm-dry branch one one-commit && + git -C pm-dry branch --set-upstream-to=origin/next one && + git -C pm-dry branch two two-commit && + git -C pm-dry branch --set-upstream-to=origin/next two && + + git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual && + test_grep "Would delete branch one " actual && + test_grep "Would delete branch two " actual && + + git -C pm-dry rev-parse --verify refs/heads/one && + git -C pm-dry rev-parse --verify refs/heads/two +' + +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' ' + test_when_finished "rm -rf pm-dry-mixed" && + git clone pm-upstream pm-dry-mixed && + git -C pm-dry-mixed remote add fork ../pm-fork && + test_config -C pm-dry-mixed remote.pushDefault fork && + test_config -C pm-dry-mixed push.default current && + git -C pm-dry-mixed checkout -b wip origin/next && + git -C pm-dry-mixed branch --set-upstream-to=origin/next wip && + test_commit -C pm-dry-mixed local-only && + git -C pm-dry-mixed checkout - && + git -C pm-dry-mixed branch merged one-commit && + git -C pm-dry-mixed branch --set-upstream-to=origin/next merged && + + git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out && + test_grep "Would delete branch merged" out && + test_grep ! "Would delete branch wip" out && + git -C pm-dry-mixed rev-parse --verify refs/heads/wip && + git -C pm-dry-mixed rev-parse --verify refs/heads/merged +' + +test_expect_success '--dry-run without --prune-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --prune-merged" err +' + test_done