From 82a974308c5b2bfc593331bf40df4b0d63800eb9 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:00 +0000 Subject: [PATCH 1/7] branch: add --forked filter for --list mode Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches . The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell glob (e.g. "origin/*"), and may be repeated to widen the filter. It is an ordinary list filter, so it combines with the others: git branch --merged origin/main --forked 'origin/*' lists branches forked from origin that are already merged into origin/main, and --no-merged inverts the question. This is the building block for --delete-merged, which deletes the listed branches once they have landed on their upstream. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 12 +++- builtin/branch.c | 18 ++++- ref-filter.c | 70 +++++++++++++++++++ ref-filter.h | 10 +++ t/t3200-branch.sh | 122 ++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 3 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index c0afddc424..b0d66a6deb 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -13,6 +13,7 @@ git branch [--color[=] | --no-color] [--show-current] [--column[=] | --no-column] [--sort=] [--merged []] [--no-merged []] [--contains []] [--no-contains []] + [(--forked )...] [--points-at ] [--format=] [(-r|--remotes) | (-a|--all)] [--list] [...] @@ -51,7 +52,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 __ 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 __ which points to the current `HEAD`, or __ if given. As a @@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main". Only list branches whose tips are not reachable from __ (`HEAD` if not specified). Implies `--list`. +`--forked `:: + Only list branches whose configured upstream matches + __. 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 `:: Only list branches of __. diff --git a/builtin/branch.c b/builtin/branch.c index 1572a4f9ef..c159f45b4c 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -30,7 +30,7 @@ #include "commit-reach.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) ..."), @@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int free_worktrees(worktrees); } +static int parse_opt_forked(const struct option *opt, const char *arg, int unset) +{ + struct ref_filter *filter = opt->value; + + BUG_ON_OPT_NEG(unset); + if (ref_filter_forked_add(filter, arg) < 0) + die(_("'%s' is not a valid branch or pattern"), arg); + return 0; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -770,6 +780,9 @@ int cmd_branch(int argc, 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")), + OPT_CALLBACK_F(0, "forked", &filter, N_("branch"), + N_("print only branches whose upstream matches (repeatable)"), + PARSE_OPT_NONEG, parse_opt_forked), OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")), OPT_REF_SORT(&sorting_options), OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"), @@ -815,7 +828,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 || filter.forked.nr) list = 1; noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + diff --git a/ref-filter.c b/ref-filter.c index 1da4c0e60d..1ddd5a3f6d 100644 --- a/ref-filter.c +++ b/ref-filter.c @@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname) return match_pattern(filter->exclude.v, refname, filter->ignore_case); } +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; +} + +/* + * Match the configured upstream of a branch against the registered + * --forked patterns. Exact patterns are compared against the full + * upstream refname so they are unambiguous; glob patterns are matched + * against the abbreviated upstream so that a glob such as origin/... + * works as typed. + */ +static int filter_forked_match(struct ref_filter *filter, const char *refname) +{ + const char *short_name; + struct branch *branch; + const char *upstream; + int i; + + if (!skip_prefix(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; + + for (i = 0; i < filter->forked.nr; i++) { + const char *pattern = filter->forked.v[i]; + if (has_glob_specials(pattern)) { + if (!wildmatch(pattern, short_upstream_name(upstream), + WM_PATHNAME)) + return 1; + } else if (!strcmp(pattern, upstream)) { + return 1; + } + } + return 0; +} + +int ref_filter_forked_add(struct ref_filter *filter, const char *arg) +{ + struct object_id oid; + char *full_ref = NULL; + + if (has_glob_specials(arg)) { + strvec_push(&filter->forked, arg); + 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/"))) { + strvec_push(&filter->forked, full_ref); + free(full_ref); + return 0; + } + free(full_ref); + return -1; +} + /* * We need to seek to the reference right after a given marker but excluding any * matching references. So we seek to the lexicographically next reference. @@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref, if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name)) return NULL; + if (filter->forked.nr && !filter_forked_match(filter, ref->name)) + return NULL; + /* * A merge filter is applied on refs pointing to commits. Hence * obtain the commit using the 'oid' available and discard all @@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter) void ref_filter_clear(struct ref_filter *filter) { strvec_clear(&filter->exclude); + strvec_clear(&filter->forked); oid_array_clear(&filter->points_at); commit_list_free(filter->with_commit); commit_list_free(filter->no_commit); diff --git a/ref-filter.h b/ref-filter.h index 120221b47f..9361296e2a 100644 --- a/ref-filter.h +++ b/ref-filter.h @@ -67,6 +67,7 @@ struct ref_filter { const char **name_patterns; const char *start_after; struct strvec exclude; + struct strvec forked; struct oid_array points_at; struct commit_list *with_commit; struct commit_list *no_commit; @@ -110,6 +111,7 @@ struct ref_format { #define REF_FILTER_INIT { \ .points_at = OID_ARRAY_INIT, \ .exclude = STRVEC_INIT, \ + .forked = STRVEC_INIT, \ } #define REF_FORMAT_INIT { \ .use_color = GIT_COLOR_UNKNOWN, \ @@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *); struct ref_sorting *ref_sorting_options(struct string_list *); /* Function to parse --merged and --no-merged options */ int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset); +/* + * Register a --forked pattern on the filter. The argument is + * either a ref, which is resolved to its full refname, or a shell-style + * glob. Branches are kept only when their configured upstream matches + * one of the registered patterns. Returns -1 if the argument is not a + * valid ref or pattern. + */ +int ref_filter_forked_add(struct ref_filter *filter, const char *arg); /* Get the current HEAD's description */ char *get_head_description(void); /* Set up translated strings in the output. */ diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index e7829c2c4b..3104c555f6 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' ' test_cmp expect actual ' +test_expect_success '--forked: setup' ' + test_create_repo forked-upstream && + ( + cd forked-upstream && + test_commit base && + git branch one base && + git branch two base + ) && + + test_create_repo forked-other && + ( + cd forked-other && + test_commit other-base && + git branch foreign other-base + ) && + + git clone forked-upstream forked && + ( + cd forked && + git remote add -f other ../forked-other && + git remote set-head origin one && + git branch local-base && + git branch --track local-one origin/one && + git branch --track local-two origin/two && + git branch --track local-foreign other/foreign && + git branch --track local-onbase local-base && + + git checkout local-one && + test_commit --no-tag local-one-work local-one.t && + git checkout local-foreign && + test_commit --no-tag local-foreign-work local-foreign.t && + git checkout --detach + ) +' + +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-onbase >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-onbase + 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 --detach" && + 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_expect_success '--forked uses the branch /HEAD points at' ' + git -C forked branch --forked origin --format="%(refname:short)" >actual && + echo local-one >expect && + test_cmp expect actual +' + +test_expect_success '--forked narrows a argument' ' + git -C forked branch --forked "origin/*" "local-*" \ + --format="%(refname:short)" >actual && + cat >expect <<-\EOF && + local-one + local-two + EOF + test_cmp expect actual +' + test_done From aaf5816f00ed850db5307f72f5d79a16f49b9dc8 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:01 +0000 Subject: [PATCH 2/7] branch: convert delete_branches() to a flags argument delete_branches() and check_branch_commit() take a pair of int booleans (force and quiet) that the next commits would grow further. Replace them with a single "unsigned int flags" argument and an enum, splitting the bits back into named bool locals so the body keeps reading the same named values. No change in behavior. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index c159f45b4c..a9be980aef 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name, return merged; } +enum delete_branch_flags { + DELETE_BRANCH_FORCE = (1 << 0), + DELETE_BRANCH_QUIET = (1 << 1), +}; + 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, unsigned int flags) { + bool force = flags & DELETE_BRANCH_FORCE; struct commit *rev = lookup_commit_reference(the_repository, oid); if (!force && !rev) { error(_("couldn't look up commit object for '%s'"), refname); @@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname) strbuf_release(&buf); } -static int delete_branches(int argc, const char **argv, int force, int kinds, - int quiet) +static int delete_branches(int argc, const char **argv, int kinds, + unsigned int flags) { struct commit *head_rev = NULL; struct object_id oid; @@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, int i; int ret = 0; int remote_branch = 0; + bool force; + bool quiet = flags & DELETE_BRANCH_QUIET; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, remote_branch = 1; allowed_interpret = INTERPRET_BRANCH_REMOTE; - force = 1; + flags |= DELETE_BRANCH_FORCE; break; case FILTER_REFS_BRANCHES: fmt = "refs/heads/%s"; @@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, } branch_name_pos = strcspn(fmt, "%"); + force = flags & DELETE_BRANCH_FORCE; + if (!force) head_rev = lookup_commit_reference(the_repository, &head_oid); for (i = 0; i < argc; i++, strbuf_reset(&bname)) { char *target = NULL; - int flags = 0; + int ref_flags = 0; copy_branchname(&bname, argv[i], allowed_interpret); free(name); @@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, RESOLVE_REF_READING | RESOLVE_REF_NO_RECURSE | RESOLVE_REF_ALLOW_BAD_NAME, - &oid, &flags); + &oid, &ref_flags); if (!target) { if (remote_branch) { error(_("remote-tracking branch '%s' not found"), bname.buf); @@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, | RESOLVE_REF_NO_RECURSE | RESOLVE_REF_ALLOW_BAD_NAME, &oid, - &flags); + &ref_flags); FREE_AND_NULL(virtual_name); if (virtual_target) @@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds, continue; } - if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) && + if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) && check_branch_commit(bname.buf, name, &oid, head_rev, kinds, - force)) { + flags)) { ret = 1; goto next; } item = string_list_append(&refs_to_delete, name); - item->util = xstrdup((flags & REF_ISBROKEN) ? "broken" - : (flags & REF_ISSYMREF) ? target + item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken" + : (ref_flags & REF_ISSYMREF) ? target : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV)); next: @@ -872,7 +882,9 @@ 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, filter.kind, + (delete > 1 ? DELETE_BRANCH_FORCE : 0) | + (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; } else if (show_current) { print_current_branch_name(); From cdce9f3c8bc5adc6eff95e0edd3c8c7f13dbe251 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:02 +0000 Subject: [PATCH 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Add a skip-unmerged mode to delete_branches() and check_branch_commit() so a bulk caller can silently skip branches that are not fully merged and carry on, rather than erroring with the "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index a9be980aef..4c569d056a 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name, enum delete_branch_flags { DELETE_BRANCH_FORCE = (1 << 0), DELETE_BRANCH_QUIET = (1 << 1), + DELETE_BRANCH_SKIP_UNMERGED = (1 << 2), }; static int check_branch_commit(const char *branchname, const char *refname, @@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname, int kinds, unsigned int flags) { bool force = flags & DELETE_BRANCH_FORCE; + bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED; struct commit *rev = lookup_commit_reference(the_repository, oid); if (!force && !rev) { error(_("couldn't look up commit object for '%s'"), 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 (!skip_unmerged) { + 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; @@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds, int remote_branch = 0; bool force; bool quiet = flags & DELETE_BRANCH_QUIET; + bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds, if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) && check_branch_commit(bname.buf, name, &oid, head_rev, kinds, flags)) { - ret = 1; + if (!skip_unmerged) + ret = 1; goto next; } From 28827a87561fc65972e34feae74cf16e367c41dc Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:03 +0000 Subject: [PATCH 4/7] branch: prepare delete_branches for a bulk caller Teach delete_branches() two new modes for the upcoming --delete-merged: one that asks only whether a branch is merged into its upstream, without falling back to HEAD when there is no upstream, and one that rehearses the deletions without removing any ref. Existing callers keep their current behavior. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/branch.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/builtin/branch.c b/builtin/branch.c index 4c569d056a..01c1f64c73 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name, * upstream, if any, otherwise with HEAD", we should just * 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) @@ -193,6 +196,7 @@ enum delete_branch_flags { DELETE_BRANCH_FORCE = (1 << 0), DELETE_BRANCH_QUIET = (1 << 1), DELETE_BRANCH_SKIP_UNMERGED = (1 << 2), + DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), }; static int check_branch_commit(const char *branchname, const char *refname, @@ -241,6 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds, bool force; bool quiet = flags & DELETE_BRANCH_QUIET; bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED; + bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -268,7 +273,7 @@ static int delete_branches(int argc, const char **argv, int kinds, force = flags & DELETE_BRANCH_FORCE; - 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)) { From 7b6e901ec88737b56014be7fc20a371aaa182b02 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:04 +0000 Subject: [PATCH 5/7] branch: add --delete-merged git branch --delete-merged ... deletes the local branches that "--forked " would list, keeping only those whose tip is reachable from their configured upstream. The work has already landed on the upstream they track, so the local copy is no longer needed. A branch is not deleted when: * it is checked out in any worktree * its upstream remote-tracking branch no longer exists, since a missing upstream is not by itself a sign of integration * its push destination equals its upstream (@{push} is the same as @{upstream}), such as a local "main" that tracks and pushes to "origin/main". Right after a pull it just looks "fully merged", so it is kept. Only branches that push somewhere other than their upstream, typically topics in a fork workflow, are candidates. A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. A branch that another, surviving branch tracks as its upstream is also kept, so a branch is never deleted out from under one stacked on top of it. Such a kept branch is itself merged, so when its own upstream is being deleted, clear its now-stale upstream config. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 29 ++++++ builtin/branch.c | 147 ++++++++++++++++++++++++++- t/t3200-branch.sh | 185 ++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+), 2 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index b0d66a6deb..66b1c87c55 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 --delete-merged ... DESCRIPTION ----------- @@ -201,6 +202,34 @@ 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 ...`:: + Delete the local branches that `--forked` would list for the + given __ 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 + __ 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, or +* its push destination (`@{push}`) equals its upstream + (`@{upstream}`), so it cannot be distinguished from a + branch that just looks "fully merged" right after a pull. +-- ++ +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. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 01c1f64c73..d12a2f57ea 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -21,6 +21,7 @@ #include "branch.h" #include "path.h" #include "string-list.h" +#include "strmap.h" #include "column.h" #include "utf8.h" #include "ref-filter.h" @@ -38,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 [] --delete-merged ..."), NULL }; @@ -705,6 +707,139 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset return 0; } +struct spare_data { + struct strset *deletable; + struct strset *spared; +}; + +/* + * A surviving branch stacked on a deletion candidate would lose its + * upstream, so drop that candidate from the delete set and remember it + * in "spared" so its own upstream can be tidied up afterwards. + */ +static int spare_stacked_base(const struct reference *ref, void *cb_data) +{ + struct spare_data *data = cb_data; + struct branch *branch; + const char *upstream, *up_short; + + if (strset_contains(data->deletable, ref->name)) + return 0; + branch = branch_get(ref->name); + upstream = branch_get_upstream(branch, NULL); + if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || + !strset_contains(data->deletable, up_short)) + return 0; + + strset_remove(data->deletable, up_short); + strset_add(data->spared, up_short); + return 0; +} + +/* + * Keep any branch that a surviving branch tracks as its upstream, so we + * never delete a branch out from under one stacked on top of it. Such a + * base is itself merged, so when its own upstream is also going away + * (no surviving branch tracks it), clear the base's now-stale upstream. + */ +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable) +{ + struct strset spared = STRSET_INIT; + struct spare_data data = { .deletable = deletable, .spared = &spared }; + struct strbuf key = STRBUF_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + + refs_for_each_branch_ref(refs, spare_stacked_base, &data); + + strset_for_each_entry(&spared, &iter, entry) { + struct branch *branch = branch_get(entry->key); + const char *upstream = branch_get_upstream(branch, NULL); + const char *up_short; + + if (!upstream || !skip_prefix(upstream, "refs/heads/", &up_short) || + !strset_contains(deletable, up_short)) + continue; + + strbuf_reset(&key); + strbuf_addf(&key, "branch.%s.merge", branch->name); + repo_config_set_gently(the_repository, key.buf, NULL); + strbuf_reset(&key); + strbuf_addf(&key, "branch.%s.remote", branch->name); + repo_config_set_gently(the_repository, key.buf, NULL); + } + + strbuf_release(&key); + strset_clear(&spared); +} + +static int delete_merged_branches(int argc, const char **argv, + unsigned int flags) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_filter filter = REF_FILTER_INIT; + struct ref_array candidates = { 0 }; + struct strset deletable = STRSET_INIT; + struct strvec to_delete = STRVEC_INIT; + struct hashmap_iter iter; + struct strmap_entry *entry; + int i, ret = 0; + + if (!argc) + die(_("--delete-merged requires at least one ")); + + 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; + 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)) + BUG("filter returned non-branch ref '%s'", full_name); + if (branch_checked_out(full_name)) + continue; + + branch = branch_get(short_name); + upstream = branch_get_upstream(branch, NULL); + if (!upstream || !refs_ref_exists(refs, upstream)) + continue; + push = branch_get_push(branch, NULL); + if (!push || !strcmp(push, upstream)) + continue; + if (check_branch_commit(short_name, short_name, + &candidates.items[i]->objectname, NULL, + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) + continue; + + strset_add(&deletable, short_name); + } + + spare_stacked_bases(refs, &deletable); + + strset_for_each_entry(&deletable, &iter, entry) + strvec_push(&to_delete, entry->key); + + if (to_delete.nr) + ret = delete_branches(to_delete.nr, to_delete.v, + FILTER_REFS_BRANCHES, + DELETE_BRANCH_SKIP_UNMERGED | + DELETE_BRANCH_NO_HEAD_FALLBACK | + flags); + + strvec_clear(&to_delete); + strset_clear(&deletable); + ref_array_clear(&candidates); + ref_filter_clear(&filter); + return ret; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -746,6 +881,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 delete_merged = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -799,6 +935,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, "delete-merged", &delete_merged, + N_("delete local branches whose upstream matches and are merged")), 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")), @@ -846,7 +984,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 && !delete_merged && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || @@ -856,7 +995,7 @@ int cmd_branch(int argc, noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!delete_merged; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); @@ -898,6 +1037,10 @@ int cmd_branch(int argc, (delete > 1 ? DELETE_BRANCH_FORCE : 0) | (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; + } else if (delete_merged) { + ret = delete_merged_branches(argc, argv, + quiet ? DELETE_BRANCH_QUIET : 0); + 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 3104c555f6..047ba54778 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a argument' ' test_cmp expect actual ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + ( + cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config remote.pushDefault fork && + git config push.default current && + git fetch other + ) +} + +merged_branch () { + ( + cd repo && + git checkout -b "$1" "$2" && + git commit --allow-empty -m "$1 work" && + git push origin "$1:next" && + git fetch origin && + git branch --set-upstream-to="$2" "$1" + ) +} + +test_expect_success '--delete-merged deletes merged branches and spares the rest' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch merged origin/next && + ( + cd repo && + git checkout -b unmerged origin/next && + git commit --allow-empty -m "unmerged work" && + git branch --set-upstream-to=origin/next unmerged && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout --detach + ) && + sha=$(git -C repo rev-parse --short merged) && + + git -C repo branch --delete-merged origin/next >actual 2>&1 && + + echo "Deleted branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + main + tracks-other + unmerged + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged deletes merged branches and spares protected ones' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch on-next origin/next && + merged_branch checked-out origin/next && + merged_branch upstream-gone origin/next && + ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local && + git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic && + git branch --set-upstream-to=origin/main main && + git config branch.main.pushRemote origin && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout checked-out + ) && + + git -C repo branch --delete-merged origin/next mainline && + + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + checked-out + main + mainline + tracks-other + upstream-gone + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged requires at least one ' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires at least one " err +' + +test_expect_success '--delete-merged keeps a branch that is an upstream' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch feature origin/next && + ( + cd repo && + git checkout -b topic feature && + git commit --allow-empty -m "topic work" && + git branch --set-upstream-to=feature topic && + git checkout --detach + ) && + + git -C repo branch --dry-run --delete-merged origin/next >out && + test_grep ! "feature" out && + + git -C repo branch --delete-merged origin/next 2>err && + + test_must_be_empty err && + git -C repo rev-parse --verify refs/heads/feature && + git -C repo rev-parse --verify refs/heads/topic && + echo origin/next >expect && + git -C repo rev-parse --abbrev-ref feature@{upstream} >actual && + test_cmp expect actual && + echo feature >expect && + git -C repo rev-parse --abbrev-ref topic@{upstream} >actual && + test_cmp expect actual +' + +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch b3 origin/next && + git branch --set-upstream-to=origin/next b3 && + git branch b2 origin/next && + git branch --set-upstream-to=b3 b2 && + git checkout -b b1 b2 && + git commit --allow-empty -m "b1 work" && + git branch --set-upstream-to=b2 b1 && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next && + + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + b1 + b2 + b3 + main + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + ( + cd repo && + git branch lower origin/next && + git branch --set-upstream-to=origin/next lower && + git branch mid origin/next && + git branch --set-upstream-to=lower mid && + git checkout -b tip mid && + git commit --allow-empty -m "tip work" && + git branch --set-upstream-to=mid tip && + git checkout --detach + ) && + + git -C repo branch --delete-merged origin/next lower && + + test_must_fail git -C repo rev-parse --verify refs/heads/lower && + git -C repo rev-parse --verify refs/heads/mid && + test_must_fail git -C repo rev-parse mid@{upstream} && + echo mid >expect && + git -C repo rev-parse --abbrev-ref tip@{upstream} >actual && + test_cmp expect actual +' + test_done From 365384b1db8fc46f391b2a77835869720c7cfc57 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:05 +0000 Subject: [PATCH 6/7] branch: add branch..deleteMerged opt-out Setting branch..deleteMerged=false exempts that branch from "git branch --delete-merged", which is useful for a topic you want to keep developing after an early round of it has been merged upstream. Unless --quiet is given, each skip is reported so the user knows why their topic was kept. Explicit deletion with "git branch -d" still uses the normal merge check and ignores this setting. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/config/branch.adoc | 7 +++++++ Documentation/git-branch.adoc | 5 +++-- builtin/branch.c | 15 +++++++++++++++ t/t3200-branch.sh | 26 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc index a4db9fa5c8..d8483acb4f 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..deleteMerged`:: + If set to `false`, branch __ is exempt from + `git branch --delete-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 66b1c87c55..d482cded3d 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -215,10 +215,11 @@ A branch is not deleted when: + -- * its upstream remote-tracking branch no longer exists, -* it is checked out in any worktree, or +* it is checked out in any worktree, * its push destination (`@{push}`) equals its upstream (`@{upstream}`), so it cannot be distinguished from a - branch that just looks "fully merged" right after a pull. + branch that just looks "fully merged" right after a pull, or +* `branch..deleteMerged` is set to `false`. -- + A branch whose work has not yet been merged into its upstream is diff --git a/builtin/branch.c b/builtin/branch.c index d12a2f57ea..bce85cb52e 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -781,8 +781,10 @@ static int delete_merged_branches(int argc, const char **argv, struct ref_array candidates = { 0 }; struct strset deletable = STRSET_INIT; struct strvec to_delete = STRVEC_INIT; + struct strbuf key = STRBUF_INIT; struct hashmap_iter iter; struct strmap_entry *entry; + bool quiet = flags & DELETE_BRANCH_QUIET; int i, ret = 0; if (!argc) @@ -800,6 +802,7 @@ static int delete_merged_branches(int argc, const char **argv, const char *short_name; struct branch *branch; const char *upstream, *push; + int opt_out; if (!skip_prefix(full_name, "refs/heads/", &short_name)) BUG("filter returned non-branch ref '%s'", full_name); @@ -818,6 +821,17 @@ static int delete_merged_branches(int argc, const char **argv, FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED)) continue; + strbuf_reset(&key); + strbuf_addf(&key, "branch.%s.deletemerged", short_name); + if (!repo_config_get_bool(the_repository, key.buf, &opt_out) && + !opt_out) { + if (!quiet) + fprintf(stderr, + _("Skipping '%s' (branch.%s.deleteMerged is false)\n"), + short_name, short_name); + continue; + } + strset_add(&deletable, short_name); } @@ -833,6 +847,7 @@ static int delete_merged_branches(int argc, const char **argv, DELETE_BRANCH_NO_HEAD_FALLBACK | flags); + strbuf_release(&key); strvec_clear(&to_delete); strset_clear(&deletable); ref_array_clear(&candidates); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 047ba54778..b7595610d9 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow test_cmp expect actual ' +test_expect_success '--delete-merged honours branch..deleteMerged=false' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch deleted origin/next && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch --delete-merged origin/next 2>err && + + test_grep "Skipping .kept." err && + test_must_fail git -C repo rev-parse --verify refs/heads/deleted && + git -C repo rev-parse --verify refs/heads/kept +' + +test_expect_success "branch -d still deletes a deleteMerged=false branch" ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch kept origin/next && + git -C repo config branch.kept.deleteMerged false && + git -C repo checkout --detach && + + git -C repo branch -d kept && + test_must_fail git -C repo rev-parse --verify refs/heads/kept +' + test_done From 4a48af9c27feb6cd1a733430dc7f84d0cf3936f9 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:55:06 +0000 Subject: [PATCH 7/7] branch: add --dry-run for --delete-merged With --dry-run, --delete-merged prints the local branches it would delete, one "Would delete branch " line each, and exits without touching any ref. The same filtering applies, so the output is exactly the set that the real run would delete. --dry-run is only meaningful together with --delete-merged and is rejected otherwise. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 8 +++++++- builtin/branch.c | 22 +++++++++++++++++++--- t/t3200-branch.sh | 11 ++++++++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index d482cded3d..00d6192e6a 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 --delete-merged ... +git branch [--dry-run] --delete-merged ... DESCRIPTION ----------- @@ -231,6 +231,12 @@ 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`:: diff --git a/builtin/branch.c b/builtin/branch.c index bce85cb52e..e7763437fb 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -199,6 +199,7 @@ enum delete_branch_flags { DELETE_BRANCH_QUIET = (1 << 1), DELETE_BRANCH_SKIP_UNMERGED = (1 << 2), DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3), + DELETE_BRANCH_DRY_RUN = (1 << 4), }; static int check_branch_commit(const char *branchname, const char *refname, @@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds, bool quiet = flags & DELETE_BRANCH_QUIET; bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED; bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK; + bool dry_run = flags & DELETE_BRANCH_DRY_RUN; struct strbuf bname = STRBUF_INIT; enum interpret_branch_kind allowed_interpret; struct string_list refs_to_delete = STRING_LIST_INIT_DUP; @@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds, free(target); } - if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) + if (!dry_run && + refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF)) ret = 1; for_each_string_list_item(item, &refs_to_delete) { char *describe_ref = item->util; char *name = item->string; - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) { + if (dry_run) { + if (!quiet) + 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 @@ -897,6 +906,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 delete_merged = 0; + int dry_run = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -952,6 +962,8 @@ int cmd_branch(int argc, N_("edit the description for the branch")), OPT_BOOL(0, "delete-merged", &delete_merged, N_("delete local branches whose upstream matches and are merged")), + OPT_BOOL(0, "dry-run", &dry_run, + N_("with --delete-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")), @@ -1014,6 +1026,9 @@ int cmd_branch(int argc, if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); + if (dry_run && !delete_merged) + die(_("--dry-run requires --delete-merged")); + if (recurse_submodules_explicit) { if (!submodule_propagate_branches) die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled")); @@ -1054,7 +1069,8 @@ int cmd_branch(int argc, goto out; } else if (delete_merged) { ret = delete_merged_branches(argc, argv, - quiet ? DELETE_BRANCH_QUIET : 0); + (quiet ? DELETE_BRANCH_QUIET : 0) | + (dry_run ? DELETE_BRANCH_DRY_RUN : 0)); goto out; } else if (show_current) { print_current_branch_name(); diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index b7595610d9..cddcde341d 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest ) && sha=$(git -C repo rev-parse --short merged) && - git -C repo branch --delete-merged origin/next >actual 2>&1 && + git -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 && + echo "Would delete branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo rev-parse --verify refs/heads/merged && + git -C repo branch --delete-merged origin/next >actual 2>&1 && echo "Deleted branch merged (was $sha)." >expect && test_cmp expect actual && git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && @@ -2050,4 +2054,9 @@ test_expect_success "branch -d still deletes a deleteMerged=false branch" ' test_must_fail git -C repo rev-parse --verify refs/heads/kept ' +test_expect_success '--dry-run without --delete-merged is rejected' ' + test_must_fail git -C forked branch --dry-run 2>err && + test_grep "requires --delete-merged" err +' + test_done