* [PATCH v11 1/6] branch: add --forked <branch>
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
` (6 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
List local branches whose configured upstream
(branch.<name>.merge resolved against branch.<name>.remote)
matches any of the given <branch> arguments.
Each <branch> is interpreted against the local repository, not
against any specific remote:
* a literal upstream short name, e.g. "origin/main" or "master"
for a branch whose upstream is local;
* a wildmatch pattern, e.g. "origin/*";
* a bare configured-remote name, e.g. "origin", which resolves
to whatever refs/remotes/origin/HEAD points at, matching how
"git checkout -b topic origin" picks a starting point.
The literal-vs-wildcard distinction is settled at parse time so
the per-branch matching loop calls wildmatch() only for genuine
wildcards. Multiple <branch> arguments are unioned. Output is
sorted by branch name.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 13 +++
builtin/branch.c | 168 +++++++++++++++++++++++++++++++++-
t/t3200-branch.sh | 90 ++++++++++++++++++
3 files changed, 269 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..a37d3a12cb 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --forked <branch>...
DESCRIPTION
-----------
@@ -199,6 +200,18 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--forked`::
+ List local branches whose configured upstream
+ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
+ matches any of the given _<branch>_ arguments.
++
+Each _<branch>_ is interpreted against the local repository: a literal
+upstream like `origin/main` or a local branch like `master`, or a
+wildmatch pattern like `'origin/*'`. A bare configured-remote name
+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
+to match the way `git checkout -b topic origin` picks a starting
+point. Multiple _<branch>_ arguments are unioned.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..2d34ad34dc 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -28,6 +28,7 @@
#include "help.h"
#include "advice.h"
#include "commit-reach.h"
+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --forked <branch>..."),
NULL
};
@@ -673,6 +675,162 @@ 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 ref_store *refs = get_main_ref_store(the_repository);
+ struct remote *remote;
+ struct object_id oid;
+ char *full_ref = NULL;
+ struct strbuf head_ref = STRBUF_INIT;
+ const char *resolved;
+
+ if (has_glob_specials(arg)) {
+ out->name = xstrdup(arg);
+ out->is_wildcard = 1;
+ return 0;
+ }
+
+ remote = remote_get(arg);
+ if (remote && remote_is_configured(remote, 0)) {
+ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
+ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
+ RESOLVE_REF_NO_RECURSE,
+ NULL, NULL);
+ if (resolved && starts_with(resolved, "refs/remotes/")) {
+ out->name = xstrdup(short_upstream_name(resolved));
+ out->is_wildcard = 0;
+ strbuf_release(&head_ref);
+ return 0;
+ }
+ strbuf_release(&head_ref);
+ }
+
+ 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(int argc, const char **argv,
+ struct upstream_pattern **patterns_out,
+ size_t *nr_out)
+{
+ struct upstream_pattern *patterns;
+ int i;
+
+ ALLOC_ARRAY(patterns, argc);
+ for (i = 0; i < argc; i++) {
+ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
+ upstream_pattern_list_clear(patterns, i);
+ die(_("'%s' is not a valid branch or pattern"),
+ argv[i]);
+ }
+ }
+ *patterns_out = patterns;
+ *nr_out = argc;
+}
+
+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;
+}
+
+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;
+ struct branch *branch;
+ const char *upstream;
+
+ if (ref->flags & REF_ISSYMREF)
+ return 0;
+ branch = branch_get(ref->name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
+ if (upstream_matches(short_upstream_name(upstream),
+ cb->patterns, cb->nr_patterns))
+ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ struct forked_cb cb;
+
+ if (!argc)
+ die(_("--forked requires at least one <branch>"));
+
+ parse_forked_args(argc, argv, &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);
+ for_each_string_list_item(item, &out)
+ puts(item->string);
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
+ string_list_clear(&out, 0);
+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -714,6 +872,7 @@ int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
+ int forked = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -767,6 +926,8 @@ int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
+ OPT_BOOL(0, "forked", &forked,
+ N_("list local branches whose upstream matches the given <branch>...")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -811,7 +972,7 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !forked && argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -820,7 +981,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!forked;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -860,6 +1021,9 @@ int cmd_branch(int argc,
die(_("branch name required"));
ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
goto out;
+ } else if (forked) {
+ ret = list_forked_branches(argc, argv);
+ 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 e7829c2c4b..013ddfb65d 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,94 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
+ git -C forked branch --forked origin/one >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> matches by wildmatch' '
+ git -C forked branch --forked "origin/*" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
+ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
+ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
+ git -C forked branch --forked origin >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked unions multiple <branch> arguments' '
+ git -C forked branch --forked origin/one other/foreign >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 "other/*" >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 "*/*" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ 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 at least one <branch>' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "at least one <branch>" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 1/6] branch: add --forked <branch> Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
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 <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 +++++++++++++++++---------
1 file changed, 17 insertions(+), 9 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 2d34ad34dc..96f6ae6dec 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -193,7 +193,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) {
@@ -201,10 +201,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;
@@ -220,7 +226,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;
@@ -310,8 +316,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;
}
@@ -1019,7 +1026,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 (forked) {
ret = list_forked_branches(argc, argv);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v11 3/6] branch: prepare delete_branches for a bulk caller
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 1/6] branch: add --forked <branch> Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add no_head_fallback and dry_run flags to delete_branches() so a bulk
caller (the upcoming --prune-merged) can ask strictly about
merged-into-upstream without a silent fallback to HEAD, and rehearse
deletions with the same "Would delete branch ..." wording as the live
run. Existing callers pass 0 for both and keep current behavior.
When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
reminder otherwise compares against HEAD. That reminder is only
meaningful when the caller actually cares about HEAD; for the
bulk caller every candidate is known to have an upstream and HEAD
is irrelevant to the decision. 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 <haraldnordgren@gmail.com>
---
builtin/branch.c | 27 +++++++++++++++++++--------
1 file changed, 19 insertions(+), 8 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 96f6ae6dec..08c1237624 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -170,10 +170,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)
@@ -226,7 +229,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;
@@ -260,7 +264,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)) {
@@ -331,13 +335,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
@@ -1027,7 +1038,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 (forked) {
ret = list_forked_branches(argc, argv);
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v11 4/6] branch: add --prune-merged <branch>
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-05-22 11:31 ` [PATCH v11 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
restricted to those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from the local refs only -- nothing is
fetched. Users who want fresh upstream refs run "git fetch" first;
the deletion path stays a separate, idempotent step that also
works offline.
Three classes of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally (its
disappearance is not, on its own, evidence of integration);
* any branch whose push destination equals its upstream
(<branch>@{push} == <branch>@{upstream}). Such a branch
cannot be distinguished from a freshly pulled trunk that
just looks "fully merged" -- e.g. local "main" tracking and
pushing to "origin/main" right after a pull. Only branches
that push somewhere other than their upstream (typically
topics in a fork-based workflow) are treated as candidates.
Deletion goes through the existing delete_branches() in warn-only
mode and with the HEAD-fallback disabled: a branch that is not
yet fully merged to its upstream is reported as a one-line warning
and skipped, so a single un-mergeable topic does not abort the
whole sweep, and there is no fallback to "merged into the
currently checked out branch" -- we only act on upstream-merged
status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 22 ++++
builtin/branch.c | 87 ++++++++++++++--
t/t3200-branch.sh | 183 ++++++++++++++++++++++++++++++++++
3 files changed, 281 insertions(+), 11 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index a37d3a12cb..c521b5f4ca 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <branch>...
+git branch --prune-merged <branch>...
DESCRIPTION
-----------
@@ -212,6 +213,27 @@ wildmatch pattern like `'origin/*'`. A bare configured-remote name
to match the way `git checkout -b topic origin` picks a starting
point. Multiple _<branch>_ arguments are unioned.
+`--prune-merged`::
+ Delete the local branches that `--forked` would list for the
+ same _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words,
+ the work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 08c1237624..1569f29573 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -821,34 +821,92 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)
return 0;
}
-static int list_forked_branches(int argc, const char **argv)
+static void collect_forked_set(int argc, const char **argv,
+ struct string_list *out)
{
struct upstream_pattern *patterns = NULL;
size_t nr_patterns = 0;
- struct string_list out = STRING_LIST_INIT_DUP;
- struct string_list_item *item;
struct forked_cb cb;
- if (!argc)
- die(_("--forked requires at least one <branch>"));
-
parse_forked_args(argc, argv, &patterns, &nr_patterns);
cb.patterns = patterns;
cb.nr_patterns = nr_patterns;
- cb.out = &out;
+ cb.out = out;
refs_for_each_branch_ref(get_main_ref_store(the_repository),
collect_forked_branch, &cb);
- string_list_sort(&out);
+ string_list_sort(out);
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
+static int list_forked_branches(int argc, const char **argv)
+{
+ struct string_list out = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+
+ if (!argc)
+ die(_("--forked requires at least one <branch>"));
+
+ collect_forked_set(argc, argv, &out);
for_each_string_list_item(item, &out)
puts(item->string);
- upstream_pattern_list_clear(patterns, nr_patterns);
string_list_clear(&out, 0);
return 0;
}
+static int prune_merged_branches(int argc, const char **argv, int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ collect_forked_set(argc, argv, &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)
@@ -891,6 +949,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
int forked = 0;
+ int prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -946,6 +1005,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "forked", &forked,
N_("list local branches whose upstream matches the given <branch>...")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches the given <branch>... and is 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")),
@@ -990,7 +1051,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && !forked && argc == 0)
+ !show_current && !unset_upstream && !forked && !prune_merged &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -999,7 +1061,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream + !!forked;
+ !!unset_upstream + !!forked + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -1043,6 +1105,9 @@ int cmd_branch(int argc,
} else if (forked) {
ret = list_forked_branches(argc, argv);
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 013ddfb65d..ad87946081 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1807,4 +1807,187 @@ test_expect_success '--forked requires at least one <branch>' '
test_grep "at least one <branch>" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "at least one <branch>" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-05-22 11:31 ` [PATCH v11 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-05-22 11:31 ` [PATCH v11 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.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 <haraldnordgren@gmail.com>
---
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.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ 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 c521b5f4ca..1bd28c4e37 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -226,9 +226,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 (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.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 1569f29573..187d5d1563 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -875,7 +875,9 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
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);
@@ -890,6 +892,18 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
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 ad87946081..da7e174e09 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1990,4 +1990,34 @@ test_expect_success '--prune-merged requires at least one <branch>' '
test_grep "at least one <branch>" err
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v11 6/6] branch: add --dry-run for --prune-merged
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-05-22 11:31 ` [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-05-22 11:31 ` Harald Nordgren via GitGitGadget
2026-06-02 13:05 ` [PATCH v11 0/6] branch: prune-merged Phillip Wood
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-05-22 11:31 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete -- one "Would delete branch <name>" line per candidate --
and exits without touching any ref.
This is the natural sanity check before letting a broad pattern
like 'origin/*' run for real: 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 <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 13 ++++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 1bd28c4e37..ee9a6354fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
git branch --forked <branch>...
-git branch --prune-merged <branch>...
+git branch --prune-merged [--dry-run] <branch>...
DESCRIPTION
-----------
@@ -235,6 +235,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 187d5d1563..7a2db11cd4 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -857,7 +857,8 @@ static int list_forked_branches(int argc, const char **argv)
return 0;
}
-static int prune_merged_branches(int argc, const char **argv, int quiet)
+static int prune_merged_branches(int argc, const char **argv, int quiet,
+ int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct string_list candidates = STRING_LIST_INIT_DUP;
@@ -914,7 +915,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)
quiet,
1, /* warn_only */
1, /* no_head_fallback */
- 0 /* dry_run */);
+ dry_run);
strvec_clear(&deletable);
string_list_clear(&candidates, 0);
@@ -964,6 +965,7 @@ int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
int forked = 0;
int prune_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -1021,6 +1023,8 @@ int cmd_branch(int argc,
N_("list local branches whose upstream matches the given <branch>...")),
OPT_BOOL(0, "prune-merged", &prune_merged,
N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ 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")),
@@ -1079,6 +1083,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged)
+ 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"));
@@ -1120,7 +1127,7 @@ int cmd_branch(int argc,
ret = list_forked_branches(argc, argv);
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
+ ret = prune_merged_branches(argc, argv, 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 da7e174e09..0e0629d19e 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2020,4 +2020,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 --prune-merged --dry-run "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 --prune-merged --dry-run "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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v11 0/6] branch: prune-merged
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-05-22 11:31 ` [PATCH v11 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-02 13:05 ` Phillip Wood
2026-06-02 13:41 ` Harald Nordgren
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
7 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-02 13:05 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
Just a quick note to say I've not forgotten about this, hopefully I
should have time to review it later in the week now I'm back on the list.
Thanks
Phillip
On 22/05/2026 12:31, Harald Nordgren via GitGitGadget wrote:
> After releasing v10, I hard-reset back to v9 and reworked the series from
> there.
>
> * The flags now take a branch, not a remote. --forked and --prune-merged
> accept a literal upstream short name like origin/main or a wildmatch
> pattern like origin/*. The old --all-remotes flag is gone, since origin/*
> covers that case.
> * The prune guard now compares @{push} against @{upstream}. A branch is
> spared when these are equal. That is the trunk like case, such as local
> main tracking and pushing to origin/main, where "fully merged to
> upstream" cannot be told apart from "just pulled". Only branches that
> push somewhere other than their upstream, typically fork based topics,
> are candidates. The earlier <remote>/HEAD by name guard that the reviewer
> rejected is gone.
> * New --dry-run for --prune-merged.
>
> Harald Nordgren (6):
> branch: add --forked <branch>
> branch: let delete_branches warn instead of error on bulk refusal
> branch: prepare delete_branches for a bulk caller
> branch: add --prune-merged <branch>
> branch: add branch.<name>.pruneMerged opt-out
> branch: add --dry-run for --prune-merged
>
> Documentation/config/branch.adoc | 7 +
> Documentation/git-branch.adoc | 42 ++++
> builtin/branch.c | 303 +++++++++++++++++++++++++--
> t/t3200-branch.sh | 347 +++++++++++++++++++++++++++++++
> 4 files changed, 682 insertions(+), 17 deletions(-)
>
>
> base-commit: aec3f587505a472db67e9462d0702e7d463a449d
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v11
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v11
> Pull-Request: https://github.com/git/git/pull/2285
>
> Range-diff vs v10:
>
> 1: f2df159830 ! 1: b9fddd124a branch: add --forked <branch>
> @@ Metadata
> ## Commit message ##
> branch: add --forked <branch>
>
> - git branch --forked <branch>...
> + List local branches whose configured upstream
> + (branch.<name>.merge resolved against branch.<name>.remote)
> + matches any of the given <branch> arguments.
>
> - lists local branches whose configured upstream matches any
> - of the given <branch> arguments.
> + Each <branch> is interpreted against the local repository, not
> + against any specific remote:
>
> - Each <branch> is resolved to the same kind of ref that
> - branch.<name>.remote and branch.<name>.merge together point at:
> - a remote-tracking branch (e.g. origin/master), or, for branches
> - tracking a local upstream, a local branch (e.g. master).
> - Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
> - arguments are unioned.
> + * a literal upstream short name, e.g. "origin/main" or "master"
> + for a branch whose upstream is local;
> + * a wildmatch pattern, e.g. "origin/*";
> + * a bare configured-remote name, e.g. "origin", which resolves
> + to whatever refs/remotes/origin/HEAD points at, matching how
> + "git checkout -b topic origin" picks a starting point.
>
> - This is the building block for --prune-merged.
> + The literal-vs-wildcard distinction is settled at parse time so
> + the per-branch matching loop calls wildmatch() only for genuine
> + wildcards. Multiple <branch> arguments are unioned. Output is
> + sorted by branch name.
> +
> + This is the building block for --prune-merged, which deletes the
> + listed branches once they have landed on their upstream.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
> nothing is printed.
>
> +`--forked`::
> -+ List local branches whose configured upstream matches any
> -+ of the given _<branch>_ arguments. Each argument is either
> -+ a ref (e.g. `origin/master`, `master`) or a shell-style
> -+ glob (e.g. `'origin/*'`). Multiple arguments are unioned.
> ++ List local branches whose configured upstream
> ++ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
> ++ matches any of the given _<branch>_ arguments.
> +++
> ++Each _<branch>_ is interpreted against the local repository: a literal
> ++upstream like `origin/main` or a local branch like `master`, or a
> ++wildmatch pattern like `'origin/*'`. A bare configured-remote name
> ++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
> ++to match the way `git checkout -b topic origin` picks a starting
> ++point. Multiple _<branch>_ arguments are unioned.
> +
> `-v`::
> `-vv`::
> @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> NULL
> };
>
> -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
> -
> - static int check_branch_commit(const char *branchname, const char *refname,
> - const struct object_id *oid, struct commit *head_rev,
> -- int kinds, int force)
> -+ int kinds, int force, int warn_only,
> -+ int *n_not_merged)
> - {
> - struct commit *rev = lookup_commit_reference(the_repository, oid);
> - if (!force && !rev) {
> -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
> - return -1;
> - }
> - if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
> -- error(_("the branch '%s' is not fully merged"), branchname);
> -- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> -- _("If you are sure you want to delete it, "
> -- "run 'git branch -D %s'"), branchname);
> -+ if (warn_only) {
> -+ warning(_("the branch '%s' is not fully merged"),
> -+ branchname);
> -+ } else {
> -+ error(_("the branch '%s' is not fully merged"),
> -+ branchname);
> -+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> -+ _("If you are sure you want to delete it, "
> -+ "run 'git branch -D %s'"), branchname);
> -+ }
> -+ if (n_not_merged)
> -+ (*n_not_merged)++;
> - return -1;
> - }
> - return 0;
> -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
> - }
> -
> - static int delete_branches(int argc, const char **argv, int force, int kinds,
> -- int quiet)
> -+ int quiet, int warn_only, int *n_not_merged)
> - {
> - struct commit *head_rev = NULL;
> - struct object_id oid;
> -@@ builtin/branch.c: 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, n_not_merged)) {
> -+ if (!warn_only)
> -+ ret = 1;
> - goto next;
> - }
> -
> @@ builtin/branch.c: 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 ref_store *refs = get_main_ref_store(the_repository);
> ++ struct remote *remote;
> ++ struct object_id oid;
> ++ char *full_ref = NULL;
> ++ struct strbuf head_ref = STRBUF_INIT;
> ++ const char *resolved;
> ++
> ++ if (has_glob_specials(arg)) {
> ++ out->name = xstrdup(arg);
> ++ out->is_wildcard = 1;
> ++ return 0;
> ++ }
> ++
> ++ remote = remote_get(arg);
> ++ if (remote && remote_is_configured(remote, 0)) {
> ++ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
> ++ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
> ++ RESOLVE_REF_NO_RECURSE,
> ++ NULL, NULL);
> ++ if (resolved && starts_with(resolved, "refs/remotes/")) {
> ++ out->name = xstrdup(short_upstream_name(resolved));
> ++ out->is_wildcard = 0;
> ++ strbuf_release(&head_ref);
> ++ return 0;
> ++ }
> ++ strbuf_release(&head_ref);
> ++ }
> ++
> ++ 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(int argc, const char **argv,
> -+ struct string_list *upstream_patterns)
> ++ struct upstream_pattern **patterns_out,
> ++ size_t *nr_out)
> +{
> ++ struct upstream_pattern *patterns;
> + int i;
> +
> ++ ALLOC_ARRAY(patterns, argc);
> + for (i = 0; i < argc; i++) {
> -+ const char *arg = argv[i];
> -+ struct object_id oid;
> -+ char *full_ref = NULL;
> -+ const char *short_ref;
> -+
> -+ if (has_glob_specials(arg)) {
> -+ string_list_insert(upstream_patterns, arg);
> -+ continue;
> ++ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
> ++ upstream_pattern_list_clear(patterns, i);
> ++ die(_("'%s' is not a valid branch or pattern"),
> ++ argv[i]);
> + }
> ++ }
> ++ *patterns_out = patterns;
> ++ *nr_out = argc;
> ++}
> +
> -+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> -+ &full_ref, 0) == 1 &&
> -+ (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
> -+ skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
> -+ string_list_insert(upstream_patterns, short_ref);
> -+ free(full_ref);
> -+ continue;
> -+ }
> -+ free(full_ref);
> ++static int upstream_matches(const char *short_upstream,
> ++ const struct upstream_pattern *patterns,
> ++ size_t nr)
> ++{
> ++ size_t i;
> +
> -+ die(_("'%s' is not a valid branch or pattern"), arg);
> ++ 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;
> +}
> +
> +struct forked_cb {
> -+ const struct string_list *upstream_patterns;
> ++ const struct upstream_pattern *patterns;
> ++ size_t nr_patterns;
> + struct string_list *out;
> +};
> +
> @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
> +{
> + struct forked_cb *cb = cb_data;
> + struct branch *branch;
> -+ const char *upstream, *short_upstream;
> -+ const struct string_list_item *item;
> ++ const char *upstream;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> -+ short_upstream = upstream;
> -+ (void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
> -+ skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
> -+
> -+ for_each_string_list_item(item, cb->upstream_patterns)
> -+ if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
> -+ string_list_append(cb->out, ref->name)->util =
> -+ xstrdup(upstream);
> -+ return 0;
> -+ }
> ++ if (upstream_matches(short_upstream_name(upstream),
> ++ cb->patterns, cb->nr_patterns))
> ++ string_list_append(cb->out, ref->name);
> + return 0;
> +}
> +
> -+static void collect_forked_set(int argc, const char **argv,
> -+ struct string_list *out)
> -+{
> -+ struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
> -+ struct forked_cb cb = {
> -+ .upstream_patterns = &upstream_patterns,
> -+ .out = out,
> -+ };
> -+
> -+ parse_forked_args(argc, argv, &upstream_patterns);
> -+
> -+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
> -+ collect_forked_branch, &cb);
> -+
> -+ string_list_clear(&upstream_patterns, 0);
> -+}
> -+
> +static int list_forked_branches(int argc, const char **argv)
> +{
> ++ struct upstream_pattern *patterns = NULL;
> ++ size_t nr_patterns = 0;
> + struct string_list out = STRING_LIST_INIT_DUP;
> + struct string_list_item *item;
> ++ struct forked_cb cb;
> +
> + if (!argc)
> + die(_("--forked requires at least one <branch>"));
> +
> -+ collect_forked_set(argc, argv, &out);
> ++ parse_forked_args(argc, argv, &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);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> -+ string_list_clear(&out, 1);
> ++ upstream_pattern_list_clear(patterns, nr_patterns);
> ++ string_list_clear(&out, 0);
> + return 0;
> +}
> +
> @@ builtin/branch.c: int cmd_branch(int argc,
> usage_with_options(builtin_branch_usage, options);
>
> @@ builtin/branch.c: int cmd_branch(int argc,
> - if (delete) {
> - if (!argc)
> die(_("branch name required"));
> -- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> -+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
> -+ quiet, 0, NULL);
> -+ goto out;
> + ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> + goto out;
> + } else if (forked) {
> + ret = list_forked_branches(argc, argv);
> - goto out;
> ++ goto out;
> } else if (show_current) {
> print_current_branch_name();
> + ret = 0;
>
> ## t/t3200-branch.sh ##
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + 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 topic-on-main main
> ++ git -C forked branch --track local-trunk local-base
> +'
> +
> -+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
> ++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
> + git -C forked branch --forked origin/one >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> -+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
> -+ git -C forked branch --forked main >actual &&
> -+ echo topic-on-main >expect &&
> -+ test_cmp expect actual
> -+'
> -+
> -+test_expect_success '--forked <glob> matches every upstream under the pattern' '
> ++test_expect_success '--forked <glob> matches by wildmatch' '
> + git -C forked branch --forked "origin/*" >actual &&
> + cat >expect <<-\EOF &&
> + local-one
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + test_cmp expect actual
> +'
> +
> ++test_expect_success '--forked <local-branch> matches branches with local upstream' '
> ++ git -C forked branch --forked local-base >actual &&
> ++ echo local-trunk >expect &&
> ++ test_cmp expect actual
> ++'
> ++
> ++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
> ++ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
> ++ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
> ++ git -C forked branch --forked origin >actual &&
> ++ echo local-one >expect &&
> ++ test_cmp expect actual
> ++'
> ++
> +test_expect_success '--forked unions multiple <branch> arguments' '
> + git -C forked branch --forked origin/one other/foreign >actual &&
> + cat >expect <<-\EOF &&
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> -+ git -C forked branch --forked main "other/*" >actual &&
> ++ git -C forked branch --forked local-base "other/*" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> -+ topic-on-main
> ++ local-trunk
> + EOF
> + test_cmp expect actual
> +'
> -: ---------- > 2: b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal
> -: ---------- > 3: 6e6580270e branch: prepare delete_branches for a bulk caller
> 2: 718e28c7e0 ! 4: e7e03c1338 branch: add --prune-merged <branch>
> @@ Commit message
>
> git branch --prune-merged <branch>...
>
> - deletes the local branches that --forked <branch> would list,
> - but only those whose tip is reachable from their configured
> - upstream: the work has already landed on the upstream the
> - branch tracks, so the local copy is no longer needed.
> + deletes the local branches that "--forked <branch>" would list,
> + restricted to those whose tip is reachable from their configured
> + upstream: the work has already landed on the upstream they track,
> + so the local copy is no longer needed.
>
> - The following branches are always preserved:
> + Reachability is read from the local refs only -- nothing is
> + fetched. Users who want fresh upstream refs run "git fetch" first;
> + the deletion path stays a separate, idempotent step that also
> + works offline.
>
> - * the currently checked-out branch in any worktree;
> - * any local branch whose name matches the default branch of
> - any configured remote (the target of
> - refs/remotes/<remote>/HEAD) -- typically 'main' or
> - 'master';
> - * any branch whose upstream no longer resolves locally.
> + Three classes of branches are spared:
>
> - Reachability is read from whatever branch.<name>.merge
> - resolves to locally, which is usually a remote-tracking ref
> - but may also be a local branch. When the upstream is a
> - remote-tracking ref, the natural workflow is
> + * any branch checked out in any worktree;
> + * any branch whose upstream no longer resolves locally (its
> + disappearance is not, on its own, evidence of integration);
> + * any branch whose push destination equals its upstream
> + (<branch>@{push} == <branch>@{upstream}). Such a branch
> + cannot be distinguished from a freshly pulled trunk that
> + just looks "fully merged" -- e.g. local "main" tracking and
> + pushing to "origin/main" right after a pull. Only branches
> + that push somewhere other than their upstream (typically
> + topics in a fork-based workflow) are treated as candidates.
>
> - git fetch <remote>
> - git branch --prune-merged <upstream-pattern>
> -
> - so the upstream reflects the current state before pruning.
> + 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, and there is no fallback to "merged into the
> + currently checked out branch" -- we only act on upstream-merged
> + status.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
>
> DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
> - a ref (e.g. `origin/master`, `master`) or a shell-style
> - glob (e.g. `'origin/*'`). Multiple arguments are unioned.
> +@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare configured-remote name
> + to match the way `git checkout -b topic origin` picks a starting
> + point. Multiple _<branch>_ arguments are unioned.
>
> +`--prune-merged`::
> -+ Delete the local branches that `--forked` would list for
> -+ the same _<branch>_ arguments, but only those whose tip is
> -+ reachable from their configured upstream.
> ++ Delete the local branches that `--forked` would list for the
> ++ same _<branch>_ arguments, but only those whose tip is
> ++ reachable from their configured upstream. In other words,
> ++ the work on the branch has already landed on the upstream it
> ++ tracks, so the local copy is no longer needed.
> ++
> -+For arguments that refer to remote-tracking branches, run
> -+`git fetch` first so reachability is checked against the
> -+current upstream state; refs are read locally.
> ++Reachability is checked against whatever the upstream refs say
> ++locally; nothing is fetched. Run `git fetch` first if you want
> ++the upstream refs refreshed.
> ++
> -+The following branches are always preserved:
> ++A branch is left alone if any of the following holds:
> ++its upstream no longer resolves locally; it is checked out in any
> ++worktree; or its push destination (`<branch>@{push}`) equals its
> ++upstream (`<branch>@{upstream}`), so it cannot be distinguished
> ++from a freshly pulled trunk that just looks "fully merged".
> ++
> -+--
> -+* the currently checked-out branch in any worktree;
> -+* any local branch whose name matches the default branch of
> -+ any configured remote (the target of
> -+ `refs/remotes/<remote>/HEAD`) -- typically `main` or
> -+ `master`;
> -+* any branch whose upstream no longer resolves locally.
> -+--
> ++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`::
>
> ## builtin/branch.c ##
> -@@
> - #include "branch.h"
> - #include "path.h"
> - #include "string-list.h"
> -+#include "strvec.h"
> - #include "column.h"
> - #include "utf8.h"
> - #include "ref-filter.h"
> -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--points-at]"),
> - N_("git branch [<options>] [-r | -a] [--format]"),
> - N_("git branch [<options>] --forked <branch>..."),
> -+ N_("git branch [<options>] --prune-merged <branch>..."),
> - NULL
> - };
> -
> -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
> - * any of the following code, but during the transition period,
> - * a gentle reminder is in order.
> - */
> -- if (head_rev != reference_rev) {
> -- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> -+ if (head_rev && head_rev != reference_rev) {
> -+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
> - if (expect < 0)
> - exit(128);
> - if (expect == merged)
> @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
> return 0;
> }
>
> -+static int collect_default_branch_name(struct remote *remote, void *cb_data)
> -+{
> -+ struct string_list *protected = cb_data;
> -+ struct ref_store *refs = get_main_ref_store(the_repository);
> -+ struct strbuf head = STRBUF_INIT;
> -+ const char *target;
> -+
> -+ strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
> -+ target = refs_resolve_ref_unsafe(refs, head.buf,
> -+ RESOLVE_REF_NO_RECURSE, NULL, NULL);
> -+ if (target) {
> -+ const char *leaf = strrchr(target, '/');
> -+ if (leaf)
> -+ string_list_insert(protected, leaf + 1);
> -+ }
> -+ strbuf_release(&head);
> -+ return 0;
> +-static int list_forked_branches(int argc, const char **argv)
> ++static void collect_forked_set(int argc, const char **argv,
> ++ struct string_list *out)
> + {
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> +- struct string_list out = STRING_LIST_INIT_DUP;
> +- struct string_list_item *item;
> + struct forked_cb cb;
> +
> +- if (!argc)
> +- die(_("--forked requires at least one <branch>"));
> +-
> + parse_forked_args(argc, argv, &patterns, &nr_patterns);
> + cb.patterns = patterns;
> + cb.nr_patterns = nr_patterns;
> +- cb.out = &out;
> ++ cb.out = out;
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> +- string_list_sort(&out);
> ++ string_list_sort(out);
> ++
> ++ upstream_pattern_list_clear(patterns, nr_patterns);
> +}
> +
> - static void collect_forked_set(int argc, const char **argv,
> - struct string_list *out)
> - {
> -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
> ++static int list_forked_branches(int argc, const char **argv)
> ++{
> ++ struct string_list out = STRING_LIST_INIT_DUP;
> ++ struct string_list_item *item;
> ++
> ++ if (!argc)
> ++ die(_("--forked requires at least one <branch>"));
> ++
> ++ collect_forked_set(argc, argv, &out);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> +- upstream_pattern_list_clear(patterns, nr_patterns);
> + string_list_clear(&out, 0);
> return 0;
> }
>
> @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> -+ struct string_list protected_default_names = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> -+ struct strbuf buf = STRBUF_INIT;
> + struct string_list_item *item;
> -+ int n_not_merged = 0;
> + int ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(argc, argv, &candidates);
> -+ for_each_remote(collect_default_branch_name, &protected_default_names);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> -+ const char *upstream = item->util;
> -+
> -+ strbuf_reset(&buf);
> -+ strbuf_addf(&buf, "refs/heads/%s", short_name);
> -+ if (branch_checked_out(buf.buf))
> ++ 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;
> +
> -+ if (string_list_has_string(&protected_default_names,
> -+ short_name))
> ++ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> ++ if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> -+
> -+ if (!refs_ref_exists(refs, upstream))
> ++ push = branch ? branch_get_push(branch, NULL) : NULL;
> ++ if (!push || !strcmp(push, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> -+ strbuf_release(&buf);
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> -+ 0, FILTER_REFS_BRANCHES, quiet,
> -+ 1, &n_not_merged);
> -+
> -+ if (n_not_merged && !quiet)
> -+ fprintf(stderr,
> -+ Q_("Skipped %d branch that is not fully merged; "
> -+ "delete it with 'git branch -D' if you are sure.\n",
> -+ "Skipped %d branches that are not fully merged; "
> -+ "delete them with 'git branch -D' if you are sure.\n",
> -+ n_not_merged),
> -+ n_not_merged);
> ++ 0, /* force */
> ++ FILTER_REFS_BRANCHES,
> ++ quiet,
> ++ 1, /* warn_only */
> ++ 1, /* no_head_fallback */
> ++ 0 /* dry_run */);
> +
> + strvec_clear(&deletable);
> -+ string_list_clear(&candidates, 1);
> -+ string_list_clear(&protected_default_names, 0);
> ++ string_list_clear(&candidates, 0);
> + return ret;
> +}
> +
> @@ builtin/branch.c: int cmd_branch(int argc,
> OPT_BOOL(0, "forked", &forked,
> N_("list local branches whose upstream matches the given <branch>...")),
> + OPT_BOOL(0, "prune-merged", &prune_merged,
> -+ N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
> ++ N_("delete local branches whose upstream matches the given <branch>... and is 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")),
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + 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
> ++ 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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged with a literal upstream argument' '
> ++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 keepme one-commit &&
> -+ git -C pm-literal branch --set-upstream-to=origin/main keepme &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> -+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
> -+ git -C pm-literal rev-parse --verify refs/heads/keepme
> ++ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> ++ git -C pm-union remote add fork ../pm-fork &&
> ++ test_config -C pm-union remote.pushDefault fork &&
> ++ test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> ++ git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next origin/main &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged with a local-branch argument' '
> -+ test_create_repo pm-local &&
> ++test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> -+ test_commit -C pm-local base &&
> -+ git -C pm-local branch topic base &&
> -+ git -C pm-local config branch.topic.remote . &&
> -+ git -C pm-local config branch.topic.merge refs/heads/main &&
> -+ git -C pm-local checkout --detach &&
> -+
> -+ git -C pm-local branch --prune-merged main &&
> -+
> -+ test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
> -+ git -C pm-local rev-parse --verify refs/heads/main
> ++ 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 spares branches with un-integrated commits' '
> ++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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> -+ test_grep "Skipped 1 branch" err &&
> -+ test_grep "git branch -D" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> ++test_expect_success '--prune-merged 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 &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> +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 &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> -+test_expect_success '--prune-merged spares the local default branch' '
> -+ test_when_finished "rm -rf pm-default" &&
> -+ git clone pm-upstream pm-default &&
> -+ git -C pm-default checkout --detach &&
> -+ git -C pm-default branch --prune-merged "origin/*" &&
> -+ git -C pm-default rev-parse --verify refs/heads/main
> ++test_expect_success '--prune-merged 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 protects the default branch by name only' '
> -+ test_when_finished "rm -rf pm-default-alias" &&
> -+ git clone pm-upstream pm-default-alias &&
> -+ git -C pm-default-alias branch --track trunk origin/main &&
> -+ git -C pm-default-alias checkout --detach &&
> -+ git -C pm-default-alias branch --prune-merged "origin/*" &&
> -+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
> -+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
> ++test_expect_success '--prune-merged 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 with literal arg also protects default-name' '
> -+ test_when_finished "rm -rf pm-literal-default" &&
> -+ git clone pm-upstream pm-literal-default &&
> -+ git -C pm-literal-default checkout --detach &&
> -+ git -C pm-literal-default branch --prune-merged origin/main &&
> -+ git -C pm-literal-default rev-parse --verify refs/heads/main
> ++test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> ++ test_when_finished "rm -rf pm-push-diff" &&
> ++ git clone pm-upstream pm-push-diff &&
> ++ git -C pm-push-diff remote add fork ../pm-fork &&
> ++ test_config -C pm-push-diff remote.pushDefault fork &&
> ++ test_config -C pm-push-diff push.default current &&
> ++ git -C pm-push-diff branch topic one-commit &&
> ++ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> ++ git -C pm-push-diff checkout --detach &&
> ++
> ++ git -C pm-push-diff branch --prune-merged "origin/*" &&
> ++
> ++ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> -+ test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
> ++ test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "at least one <branch>" err
> +'
> +
> 3: 6e38d7af3a ! 5: 75b6d2366a branch: add branch.<name>.pruneMerged opt-out
> @@ Metadata
> ## Commit message ##
> branch: add branch.<name>.pruneMerged opt-out
>
> - Setting branch.<name>.pruneMerged=false exempts that branch
> - from --prune-merged. Useful for topic branches you intend to
> - develop further after an initial round has been merged
> + Setting branch.<name>.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.
>
> - Explicit deletion via 'git branch -d' is unaffected.
> + 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 <haraldnordgren@gmail.com>
>
> @@ Documentation/config/branch.adoc: for details).
> +
> +`branch.<name>.pruneMerged`::
> + If set to `false`, branch _<name>_ is exempt from
> -+ `git branch --prune-merged`. Defaults to true. Explicit
> -+ deletion via `git branch -d` is unaffected.
> ++ `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.
>
> ## Documentation/git-branch.adoc ##
> -@@ Documentation/git-branch.adoc: The following branches are always preserved:
> - any configured remote (the target of
> - `refs/remotes/<remote>/HEAD`) -- typically `main` or
> - `master`;
> -+* any branch with `branch.<name>.pruneMerged` set to `false`;
> - * any branch whose upstream no longer resolves locally.
> - --
> -
> +@@ Documentation/git-branch.adoc: the upstream refs refreshed.
> + +
> + A branch is left alone if any of the following holds:
> + its upstream no longer resolves locally; it is checked out in any
> +-worktree; or its push destination (`<branch>@{push}`) equals its
> ++worktree; its push destination (`<branch>@{push}`) equals its
> + upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +-from a freshly pulled trunk that just looks "fully merged".
> ++from a freshly pulled trunk that just looks "fully merged"; or
> ++`branch.<name>.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
>
> ## builtin/branch.c ##
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - for_each_string_list_item(item, &candidates) {
> - const char *short_name = item->string;
> - const char *upstream = item->util;
> -+ int prune_allowed = 1;
> + 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_reset(&buf);
> - strbuf_addf(&buf, "refs/heads/%s", short_name);
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + skip = !!branch_checked_out(full.buf);
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - if (!refs_ref_exists(refs, upstream))
> + if (!push || !strcmp(push, upstream))
> continue;
>
> -+ strbuf_reset(&buf);
> -+ strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
> -+ if (!repo_config_get_bool(the_repository, buf.buf,
> -+ &prune_allowed) &&
> -+ !prune_allowed) {
> ++ 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"),
> ++ 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);
> }
> - strbuf_release(&buf);
> +
>
> ## t/t3200-branch.sh ##
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> + test_when_finished "rm -rf pm-optout" &&
> + git clone pm-upstream pm-optout &&
> ++ git -C pm-optout remote add fork ../pm-fork &&
> ++ test_config -C pm-optout remote.pushDefault fork &&
> ++ test_config -C pm-optout push.default current &&
> + git -C pm-optout branch one one-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next one &&
> + git -C pm-optout branch two two-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next two &&
> -+ git -C pm-optout config branch.one.pruneMerged false &&
> ++ test_config -C pm-optout branch.one.pruneMerged false &&
> +
> + git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
> + 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 &&
> -+ git -C pm-optout-d config branch.one.pruneMerged false &&
> ++ 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
> 4: c68d162e22 ! 6: a1a42a6b19 branch: add --dry-run for --prune-merged
> @@ Metadata
> ## Commit message ##
> branch: add --dry-run for --prune-merged
>
> - With --dry-run, --prune-merged prints the branches it would
> - delete and exits without touching any ref. Useful for
> - sanity-checking a glob like 'origin/*' before letting it run.
> + With --dry-run, --prune-merged prints the local branches it would
> + delete -- one "Would delete branch <name>" line per candidate --
> + and exits without touching any ref.
> +
> + This is the natural sanity check before letting a broad pattern
> + like 'origin/*' run for real: 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 <haraldnordgren@gmail.com>
>
> @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
>
> DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: The following branches are always preserved:
> - * any branch whose upstream no longer resolves locally.
> - --
> +@@ Documentation/git-branch.adoc: 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 the branches that would be
> -+ deleted instead of deleting them.
> ++ 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`::
>
> ## builtin/branch.c ##
> -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--points-at]"),
> - N_("git branch [<options>] [-r | -a] [--format]"),
> - N_("git branch [<options>] --forked <branch>..."),
> -- N_("git branch [<options>] --prune-merged <branch>..."),
> -+ N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
> - NULL
> - };
> -
> -@@ builtin/branch.c: 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 *n_not_merged)
> -+ int quiet, int warn_only, int dry_run,
> -+ int *n_not_merged)
> - {
> - struct commit *head_rev = NULL;
> - struct object_id oid;
> -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
> - goto next;
> - }
> -
> -+ if (dry_run) {
> -+ printf(_("Would delete branch '%s'\n"),
> -+ name + branch_name_pos);
> -+ goto next;
> -+ }
> -+
> - item = string_list_append(&refs_to_delete, name);
> - item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
> - : (flags & REF_ISSYMREF) ? target
> @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
> return 0;
> }
>
> -static int prune_merged_branches(int argc, const char **argv, int quiet)
> -+static int prune_merged_branches(int argc, const char **argv,
> -+ int dry_run, int quiet)
> ++static int prune_merged_branches(int argc, const char **argv, int quiet,
> ++ int dry_run)
> {
> struct ref_store *refs = get_main_ref_store(the_repository);
> struct string_list candidates = STRING_LIST_INIT_DUP;
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - if (deletable.nr)
> - ret = delete_branches(deletable.nr, deletable.v,
> - 0, FILTER_REFS_BRANCHES, quiet,
> -- 1, &n_not_merged);
> -+ 1, dry_run, &n_not_merged);
> + quiet,
> + 1, /* warn_only */
> + 1, /* no_head_fallback */
> +- 0 /* dry_run */);
> ++ dry_run);
>
> - if (n_not_merged && !quiet)
> - fprintf(stderr,
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> @@ builtin/branch.c: int cmd_branch(int argc,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> int forked = 0;
> @@ builtin/branch.c: int cmd_branch(int argc,
> @@ builtin/branch.c: int cmd_branch(int argc,
> N_("list local branches whose upstream matches the given <branch>...")),
> OPT_BOOL(0, "prune-merged", &prune_merged,
> - N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
> + N_("delete local branches whose upstream matches the given <branch>... and is merged")),
> + OPT_BOOL(0, "dry-run", &dry_run,
> -+ N_("with --prune-merged, only print what would be deleted")),
> ++ 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")),
> @@ builtin/branch.c: int cmd_branch(int argc,
> - argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
> - 0);
> + if (noncreate_actions > 1)
> + usage_with_options(builtin_branch_usage, options);
>
> + if (dry_run && !prune_merged)
> + die(_("--dry-run requires --prune-merged"));
> +
> - if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && !forked && !prune_merged &&
> - argc == 0)
> + if (recurse_submodules_explicit) {
> + if (!submodule_propagate_branches)
> + die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
> @@ builtin/branch.c: int cmd_branch(int argc,
> - if (!argc)
> - die(_("branch name required"));
> - ret = delete_branches(argc, argv, delete > 1, filter.kind,
> -- quiet, 0, NULL);
> -+ quiet, 0, 0, NULL);
> - goto out;
> - } else if (forked) {
> ret = list_forked_branches(argc, argv);
> goto out;
> } else if (prune_merged) {
> - ret = prune_merged_branches(argc, argv, quiet);
> -+ ret = prune_merged_branches(argc, argv, dry_run, quiet);
> ++ ret = prune_merged_branches(argc, argv, quiet, dry_run);
> goto out;
> } else if (show_current) {
> print_current_branch_name();
> @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
> test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> '
>
> -+test_expect_success '--prune-merged --dry-run prints but does not delete' '
> -+ test_when_finished "rm -rf pm-dryrun" &&
> -+ git clone pm-upstream pm-dryrun &&
> -+ git -C pm-dryrun branch one one-commit &&
> -+ git -C pm-dryrun branch --set-upstream-to=origin/next 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 --prune-merged --dry-run "origin/*" >actual &&
> ++ test_grep "Would delete branch one " actual &&
> ++ test_grep "Would delete branch two " actual &&
> +
> -+ git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
> -+ test_grep "Would delete branch .one." out &&
> -+ git -C pm-dryrun rev-parse --verify refs/heads/one
> ++ 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 skips un-integrated branches' '
> -+ test_when_finished "rm -rf pm-dryrun-unmerged" &&
> -+ git clone pm-upstream pm-dryrun-unmerged &&
> -+ git -C pm-dryrun-unmerged checkout -b wip origin/next &&
> -+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
> -+ test_commit -C pm-dryrun-unmerged local-only &&
> -+ git -C pm-dryrun-unmerged checkout - &&
> -+ git -C pm-dryrun-unmerged branch merged one-commit &&
> -+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
> ++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-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
> -+ >out 2>err &&
> -+ test_grep "Would delete branch .merged." out &&
> -+ test_grep ! "Would delete branch .wip." out &&
> -+ test_grep "not fully merged" err &&
> -+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
> -+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
> ++ git -C pm-dry-mixed branch --prune-merged --dry-run "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 requires --prune-merged' '
> -+ test_must_fail git -C pm-upstream branch --dry-run 2>err &&
> ++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
> +'
> +
>
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v12 0/6] branch: prune-merged
2026-05-22 11:31 ` [PATCH v11 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (6 preceding siblings ...)
2026-06-02 13:05 ` [PATCH v11 0/6] branch: prune-merged Phillip Wood
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-03 9:04 ` [PATCH v12 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (6 more replies)
7 siblings, 7 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Harald Nordgren (6):
branch: add --forked filter for --list mode
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 37 ++++
builtin/branch.c | 317 +++++++++++++++++++++++++--
ref-filter.c | 10 +-
ref-filter.h | 2 +
t/t3200-branch.sh | 354 +++++++++++++++++++++++++++++++
6 files changed, 701 insertions(+), 26 deletions(-)
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v12
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v12
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v11:
1: b9fddd124a ! 1: 8834c424fb branch: add --forked <branch>
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: add --forked <branch>
+ branch: add --forked filter for --list mode
- List local branches whose configured upstream
- (branch.<name>.merge resolved against branch.<name>.remote)
- matches any of the given <branch> arguments.
+ Add a --forked option to "git branch" list mode that keeps only
+ branches whose configured upstream matches <branch>. The argument
+ can be a ref (e.g. "origin/main", "master") or a shell-style
+ glob (e.g. "origin/*"). The option can be repeated to widen the
+ filter.
- Each <branch> is interpreted against the local repository, not
- against any specific remote:
+ Because it is a filter on list mode, --forked composes with the
+ existing list-mode filters, so
- * a literal upstream short name, e.g. "origin/main" or "master"
- for a branch whose upstream is local;
- * a wildmatch pattern, e.g. "origin/*";
- * a bare configured-remote name, e.g. "origin", which resolves
- to whatever refs/remotes/origin/HEAD points at, matching how
- "git checkout -b topic origin" picks a starting point.
+ git branch --merged origin/main --forked 'origin/*'
- The literal-vs-wildcard distinction is settled at parse time so
- the per-branch matching loop calls wildmatch() only for genuine
- wildcards. Multiple <branch> arguments are unioned. Output is
- sorted by branch name.
+ 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.
@@ Commit message
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
- git branch (-c|-C) [<old-branch>] <new-branch>
- git branch (-d|-D) [-r] <branch-name>...
- git branch --edit-description [<branch-name>]
-+git branch --forked <branch>...
-
- DESCRIPTION
- -----------
+@@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]
+ [--merged [<commit>]] [--no-merged [<commit>]]
+ [--contains [<commit>]] [--no-contains [<commit>]]
+ [--points-at <object>] [--format=<format>]
++ [(--forked <branch>)...]
+ [(-r|--remotes) | (-a|--all)]
+ [--list] [<pattern>...]
+ git branch [--track[=(direct|inherit)] | --no-track] [-f]
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
-+`--forked`::
-+ List local branches whose configured upstream
-+ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
-+ matches any of the given _<branch>_ arguments.
-++
-+Each _<branch>_ is interpreted against the local repository: a literal
-+upstream like `origin/main` or a local branch like `master`, or a
-+wildmatch pattern like `'origin/*'`. A bare configured-remote name
-+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
-+to match the way `git checkout -b topic origin` picks a starting
-+point. Multiple _<branch>_ arguments are unioned.
++`--forked <branch>`::
++ List only branches whose configured upstream matches
++ _<branch>_. The argument can be a ref (e.g. `origin/main`,
++ `master`) or a shell-style glob (e.g. `'origin/*'`). The
++ option can be repeated to widen the filter.
+
`-v`::
`-vv`::
@@ builtin/branch.c
+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
-@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
- N_("git branch [<options>] [-r | -a] [--points-at]"),
- N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] --forked <branch>..."),
- NULL
- };
+- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
++ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
+ N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
+ N_("git branch [<options>] [-l] [<pattern>...]"),
+ N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
+@@ builtin/branch.c: 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;
+@@ builtin/branch.c: 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));
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
free_worktrees(worktrees);
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+
+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
+{
-+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct remote *remote;
+ struct object_id oid;
+ char *full_ref = NULL;
-+ struct strbuf head_ref = STRBUF_INIT;
-+ const char *resolved;
+
+ if (has_glob_specials(arg)) {
+ out->name = xstrdup(arg);
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return 0;
+ }
+
-+ remote = remote_get(arg);
-+ if (remote && remote_is_configured(remote, 0)) {
-+ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
-+ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
-+ RESOLVE_REF_NO_RECURSE,
-+ NULL, NULL);
-+ if (resolved && starts_with(resolved, "refs/remotes/")) {
-+ out->name = xstrdup(short_upstream_name(resolved));
-+ out->is_wildcard = 0;
-+ strbuf_release(&head_ref);
-+ return 0;
-+ }
-+ strbuf_release(&head_ref);
-+ }
-+
+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
+ &full_ref, 0) == 1 &&
+ (starts_with(full_ref, "refs/heads/") ||
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return -1;
+}
+
-+static void parse_forked_args(int argc, const char **argv,
++static void parse_forked_args(const struct string_list *args,
+ struct upstream_pattern **patterns_out,
+ size_t *nr_out)
+{
+ struct upstream_pattern *patterns;
-+ int i;
++ size_t i;
+
-+ ALLOC_ARRAY(patterns, argc);
-+ for (i = 0; i < argc; i++) {
-+ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
++ 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"),
-+ argv[i]);
++ die(_("'%s' is not a valid branch or pattern"), arg);
+ }
+ }
+ *patterns_out = patterns;
-+ *nr_out = argc;
++ *nr_out = args->nr;
+}
+
+static int upstream_matches(const char *short_upstream,
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ return 0;
+}
+
-+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)
++static int branch_upstream_matches(const char *full_refname,
++ const struct upstream_pattern *patterns,
++ size_t nr_patterns)
+{
-+ struct forked_cb *cb = cb_data;
++ const char *short_name;
+ struct branch *branch;
+ const char *upstream;
+
-+ if (ref->flags & REF_ISSYMREF)
++ if (!skip_prefix(full_refname, "refs/heads/", &short_name))
+ return 0;
-+ branch = branch_get(ref->name);
++ branch = branch_get(short_name);
+ if (!branch)
+ return 0;
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
-+ if (upstream_matches(short_upstream_name(upstream),
-+ cb->patterns, cb->nr_patterns))
-+ string_list_append(cb->out, ref->name);
-+ return 0;
++ return upstream_matches(short_upstream_name(upstream),
++ patterns, nr_patterns);
+}
+
-+static int list_forked_branches(int argc, const char **argv)
++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;
-+ struct string_list out = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
-+ struct forked_cb cb;
-+
-+ if (!argc)
-+ die(_("--forked requires at least one <branch>"));
++ int i, kept = 0;
+
-+ parse_forked_args(argc, argv, &patterns, &nr_patterns);
-+ cb.patterns = patterns;
-+ cb.nr_patterns = nr_patterns;
-+ cb.out = &out;
++ parse_forked_args(upstreams, &patterns, &nr_patterns);
+
-+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
-+ collect_forked_branch, &cb);
-+
-+ string_list_sort(&out);
-+ for_each_string_list_item(item, &out)
-+ puts(item->string);
++ 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);
-+ string_list_clear(&out, 0);
-+ return 0;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
@@ builtin/branch.c: int cmd_branch(int argc,
/* possible actions */
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
-+ int forked = 0;
++ struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
-+ OPT_BOOL(0, "forked", &forked,
-+ N_("list local branches whose upstream matches the given <branch>...")),
++ OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
++ N_("list local branches whose upstream matches <branch> (repeatable)")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ builtin/branch.c: int cmd_branch(int argc,
- 0);
-
- if (!delete && !rename && !copy && !edit_description && !new_upstream &&
-- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !forked && argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
-@@ builtin/branch.c: int cmd_branch(int argc,
+- 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 +
- !!show_current + !!list + !!edit_description +
-- !!unset_upstream;
-+ !!unset_upstream + !!forked;
- if (noncreate_actions > 1)
- usage_with_options(builtin_branch_usage, options);
-
@@ builtin/branch.c: int cmd_branch(int argc,
- die(_("branch name required"));
- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
- goto out;
-+ } else if (forked) {
-+ ret = list_forked_branches(argc, argv);
-+ goto out;
- } else if (show_current) {
- print_current_branch_name();
- ret = 0;
+ 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);
+@@ builtin/branch.c: int cmd_branch(int argc,
+
+ out:
+ string_list_clear(&sorting_options, 0);
++ string_list_clear(&forked_upstreams, 0);
+ return ret;
+ }
+
+ ## ref-filter.c ##
+@@ ref-filter.c: 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) {
+@@ ref-filter.c: 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
+@@ ref-filter.c: 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;
+
+@@ ref-filter.c: 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);
+@@ ref-filter.c: 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)
+
+ ## ref-filter.h ##
+@@ ref-filter.h: 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 */
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ git -C forked branch --track local-trunk local-base
+'
+
-+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
-+ git -C forked branch --forked origin/one >actual &&
++test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
++ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked <glob> matches by wildmatch' '
-+ git -C forked branch --forked "origin/*" >actual &&
++test_expect_success '--forked <glob> filters by wildmatch' '
++ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
-+ git -C forked branch --forked local-base >actual &&
++ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
-+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
-+ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
-+ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
-+ git -C forked branch --forked origin >actual &&
-+ echo local-one >expect &&
-+ test_cmp expect actual
-+'
-+
-+test_expect_success '--forked unions multiple <branch> arguments' '
-+ git -C forked branch --forked origin/one other/foreign >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
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
-+ git -C forked branch --forked local-base "other/*" >actual &&
++ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
-+ git -C forked branch --forked "*/*" >actual &&
++ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ 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 at least one <branch>' '
++test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
-+ test_grep "at least one <branch>" err
++ test_grep "requires a value" err
+'
+
test_done
2: b666d09bf5 ! 2: 6c95e4e77c branch: let delete_branches warn instead of error on bulk refusal
@@ Commit message
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.
+ emits. Default callers pass 0 and are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: int cmd_branch(int argc,
+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ quiet, 0);
goto out;
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ } else if (show_current) {
+ print_current_branch_name();
3: 6e6580270e ! 3: 004a96f7a4 branch: prepare delete_branches for a bulk caller
@@ Metadata
## Commit message ##
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.
+ 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. That reminder is only
- meaningful when the caller actually cares about HEAD; for the
- bulk caller every candidate is known to have an upstream and HEAD
- is irrelevant to the decision. 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.
+ 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 <haraldnordgren@gmail.com>
@@ builtin/branch.c: int cmd_branch(int argc,
- quiet, 0);
+ quiet, 0, 0, 0);
goto out;
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ } else if (show_current) {
+ print_current_branch_name();
4: e7e03c1338 ! 4: cccfdb831c branch: add --prune-merged <branch>
@@ Commit message
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
- Reachability is read from the local refs only -- nothing is
- fetched. Users who want fresh upstream refs run "git fetch" first;
- the deletion path stays a separate, idempotent step that also
- works offline.
+ 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:
@@ Commit message
* any branch whose push destination equals its upstream
(<branch>@{push} == <branch>@{upstream}). Such a branch
cannot be distinguished from a freshly pulled trunk that
- just looks "fully merged" -- e.g. local "main" tracking and
+ 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.
@@ Commit message
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, and there is no fallback to "merged into the
- currently checked out branch" -- we only act on upstream-merged
- status.
+ whole sweep. We only act on upstream-merged status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
+@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
+ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
- git branch --forked <branch>...
-+git branch --prune-merged <branch>...
++git branch (--prune-merged <branch>)...
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare configured-remote name
- to match the way `git checkout -b topic origin` picks a starting
- point. Multiple _<branch>_ arguments are unioned.
+@@ Documentation/git-branch.adoc: 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`::
++`--prune-merged <branch>`::
+ Delete the local branches that `--forked` would list for the
-+ same _<branch>_ arguments, but only those whose tip is
-+ reachable from their configured upstream. In other words,
-+ the work on the branch has already landed on the upstream it
-+ tracks, so the local copy is no longer needed.
++ same _<branch>_, but only those whose tip is reachable from
++ their configured upstream. In other words, the work on the
++ branch has already landed on the upstream it tracks, so the
++ local copy is no longer needed. May be given more than once to
++ union the matches; positional arguments are not accepted.
++
+Reachability is checked against whatever the upstream refs say
-+locally; nothing is fetched. Run `git fetch` first if you want
++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:
@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare conf
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
+@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
+ N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
+ N_("git branch [<options>] [-r | -a] [--points-at]"),
+ N_("git branch [<options>] [-r | -a] [--format]"),
++ N_("git branch [<options>] (--prune-merged <branch>)..."),
+ NULL
+ };
+
+@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
return 0;
}
--static int list_forked_branches(int argc, const char **argv)
-+static void collect_forked_set(int argc, const char **argv,
-+ struct string_list *out)
+-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)
{
- struct upstream_pattern *patterns = NULL;
- size_t nr_patterns = 0;
-- struct string_list out = STRING_LIST_INIT_DUP;
-- struct string_list_item *item;
- struct forked_cb cb;
+- const char *short_name;
+- struct branch *branch;
++ struct branch *branch = branch_get(short_branch_name);
+ const char *upstream;
-- if (!argc)
-- die(_("--forked requires at least one <branch>"));
--
- parse_forked_args(argc, argv, &patterns, &nr_patterns);
- cb.patterns = patterns;
- cb.nr_patterns = nr_patterns;
-- cb.out = &out;
-+ cb.out = out;
+- 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);
+@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
- refs_for_each_branch_ref(get_main_ref_store(the_repository),
- collect_forked_branch, &cb);
+ 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);
+@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
+ upstream_pattern_list_clear(patterns, nr_patterns);
+ }
-- string_list_sort(&out);
-+ string_list_sort(out);
++struct forked_cb {
++ const struct upstream_pattern *patterns;
++ size_t nr_patterns;
++ struct string_list *out;
++};
+
-+ upstream_pattern_list_clear(patterns, nr_patterns);
++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 int list_forked_branches(int argc, const char **argv)
++static void collect_forked_set(const struct string_list *upstreams,
++ struct string_list *out)
+{
-+ struct string_list out = STRING_LIST_INIT_DUP;
-+ struct string_list_item *item;
++ struct upstream_pattern *patterns = NULL;
++ size_t nr_patterns = 0;
++ struct forked_cb cb;
+
-+ if (!argc)
-+ die(_("--forked requires at least one <branch>"));
++ parse_forked_args(upstreams, &patterns, &nr_patterns);
++ cb.patterns = patterns;
++ cb.nr_patterns = nr_patterns;
++ cb.out = out;
+
-+ collect_forked_set(argc, argv, &out);
- for_each_string_list_item(item, &out)
- puts(item->string);
-
-- upstream_pattern_list_clear(patterns, nr_patterns);
- string_list_clear(&out, 0);
- return 0;
- }
-
-+static int prune_merged_branches(int argc, const char **argv, int quiet)
++ 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;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
+ struct string_list_item *item;
+ int ret = 0;
+
-+ if (!argc)
++ if (!upstreams->nr)
+ die(_("--prune-merged requires at least one <branch>"));
+
-+ collect_forked_set(argc, argv, &candidates);
++ collect_forked_set(upstreams, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int forked = 0;
-+ int prune_merged = 0;
+ 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 */
@@ builtin/branch.c: int cmd_branch(int argc,
N_("edit the description for the branch")),
- OPT_BOOL(0, "forked", &forked,
- N_("list local branches whose upstream matches the given <branch>...")),
-+ OPT_BOOL(0, "prune-merged", &prune_merged,
-+ N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
+ N_("list local branches whose upstream matches <branch> (repeatable)")),
++ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
++ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ builtin/branch.c: int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
-- !show_current && !unset_upstream && !forked && argc == 0)
-+ !show_current && !unset_upstream && !forked && !prune_merged &&
+- !show_current && !unset_upstream && argc == 0)
++ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
-- !!unset_upstream + !!forked;
-+ !!unset_upstream + !!forked + !!prune_merged;
+- !!unset_upstream;
++ !!unset_upstream + !!prune_merged_upstreams.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
- } else if (forked) {
- ret = list_forked_branches(argc, argv);
+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
+ quiet, 0, 0, 0);
goto out;
-+ } else if (prune_merged) {
-+ ret = prune_merged_branches(argc, argv, quiet);
++ } else if (prune_merged_upstreams.nr) {
++ if (argc)
++ die(_("--prune-merged does not take positional arguments; "
++ "repeat --prune-merged for each <branch>"));
++ ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
+@@ builtin/branch.c: 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;
+ }
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '
- test_grep "at least one <branch>" err
+@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
-+ git -C pm-union branch --prune-merged origin/next origin/main &&
++ 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
@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
-+test_expect_success '--prune-merged requires at least one <branch>' '
++test_expect_success '--prune-merged requires a value' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
-+ test_grep "at least one <branch>" 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
5: 75b6d2366a ! 5: 5f793f8d0d branch: add branch.<name>.pruneMerged opt-out
@@ Documentation/git-branch.adoc: the upstream refs refreshed.
warnings and skipped; pass them to `git branch -D` explicitly if
## builtin/branch.c ##
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: 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;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
strbuf_addf(&full, "refs/heads/%s", short_name);
skip = !!branch_checked_out(full.buf);
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
if (!push || !strcmp(push, upstream))
continue;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
- test_grep "at least one <branch>" err
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
+ test_grep "does not take positional arguments" err
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
6: a1a42a6b19 ! 6: 1a0d5eab15 branch: add --dry-run for --prune-merged
@@ Commit message
branch: add --dry-run for --prune-merged
With --dry-run, --prune-merged prints the local branches it would
- delete -- one "Would delete branch <name>" line per candidate --
- and exits without touching any ref.
+ delete, one "Would delete branch <name>" line per candidate, and
+ exits without touching any ref.
- This is the natural sanity check before letting a broad pattern
- like 'origin/*' run for real: the @{push}-vs-@{upstream} and
- unmerged filtering still applies, so the dry-run output is
- exactly the set that the live run would delete.
+ 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.
@@ Commit message
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
+@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
+ git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
- git branch --forked <branch>...
--git branch --prune-merged <branch>...
-+git branch --prune-merged [--dry-run] <branch>...
+-git branch (--prune-merged <branch>)...
++git branch [--dry-run] (--prune-merged <branch>)...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety che
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
- return 0;
+@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,
}
--static int prune_merged_branches(int argc, const char **argv, int quiet)
-+static int prune_merged_branches(int argc, const char **argv, int quiet,
-+ int dry_run)
+ 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;
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
+@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
quiet,
1, /* warn_only */
1, /* no_head_fallback */
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
string_list_clear(&candidates, 0);
@@ builtin/branch.c: int cmd_branch(int argc,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int forked = 0;
- int prune_merged = 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 */
@@ builtin/branch.c: int cmd_branch(int argc,
- N_("list local branches whose upstream matches the given <branch>...")),
- OPT_BOOL(0, "prune-merged", &prune_merged,
- N_("delete local branches whose upstream matches the given <branch>... and is merged")),
+ N_("list local branches whose upstream matches <branch> (repeatable)")),
+ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
+ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
+ OPT_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),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !prune_merged)
++ 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"));
@@ builtin/branch.c: int cmd_branch(int argc,
- ret = list_forked_branches(argc, argv);
- goto out;
- } else if (prune_merged) {
-- ret = prune_merged_branches(argc, argv, quiet);
-+ ret = prune_merged_branches(argc, argv, quiet, dry_run);
+ if (argc)
+ die(_("--prune-merged does not take positional arguments; "
+ "repeat --prune-merged for each <branch>"));
+- 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();
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ git -C pm-dry branch two two-commit &&
+ git -C pm-dry branch --set-upstream-to=origin/next two &&
+
-+ git -C pm-dry branch --prune-merged --dry-run "origin/*" >actual &&
++ 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 &&
+
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ 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 --prune-merged --dry-run "origin/*" >out &&
++ 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 &&
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v12 1/6] branch: add --forked filter for --list mode
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-05 13:48 ` Phillip Wood
2026-06-03 9:04 ` [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that keeps only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell-style
glob (e.g. "origin/*"). The option can be repeated to widen the
filter.
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 <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 7 ++
builtin/branch.c | 147 +++++++++++++++++++++++++++++++++-
ref-filter.c | 10 +--
ref-filter.h | 2 +
t/t3200-branch.sh | 92 +++++++++++++++++++++
5 files changed, 249 insertions(+), 9 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..8002d7f38c 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
[--points-at <object>] [--format=<format>]
+ [(--forked <branch>)...]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
git branch [--track[=(direct|inherit)] | --no-track] [-f]
@@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--forked <branch>`::
+ List only branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..12711b29cf 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -28,9 +28,10 @@
#include "help.h"
#include "advice.h"
#include "commit-reach.h"
+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
return strbuf_detach(&fmt, NULL);
}
+static void filter_array_by_forked(struct ref_array *array,
+ const struct string_list *upstreams);
+
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 <branch> (repeatable)")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -815,7 +951,8 @@ int cmd_branch(int argc,
list = 1;
if (filter.with_commit || filter.no_commit ||
- filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
+ filter.reachable_from || filter.unreachable_from ||
+ filter.points_at.nr || forked_upstreams.nr)
list = 1;
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
@@ -880,7 +1017,8 @@ int cmd_branch(int argc,
ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
ref_sorting_set_sort_flags_all(
sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
- print_ref_list(&filter, sorting, &format, &output);
+ print_ref_list(&filter, sorting, &format, &output,
+ &forked_upstreams);
print_columns(&output, colopts, NULL);
string_list_clear(&output, 0);
ref_sorting_release(sorting);
@@ -1020,5 +1158,6 @@ int cmd_branch(int argc,
out:
string_list_clear(&sorting_options, 0);
+ string_list_clear(&forked_upstreams, 0);
return ret;
}
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..65e7bc6785 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)
}
/* Free memory allocated for a ref_array_item */
-static void free_array_item(struct ref_array_item *item)
+void free_ref_array_item(struct ref_array_item *item)
{
free((char *)item->symref);
if (item->value) {
@@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)
strbuf_release(&output);
strbuf_release(&err);
- free_array_item(item);
+ free_ref_array_item(item);
/*
* Increment the running count of refs that match the filter. If
@@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)
int i;
for (i = 0; i < array->nr; i++)
- free_array_item(array->items[i]);
+ free_ref_array_item(array->items[i]);
FREE_AND_NULL(array->items);
array->nr = array->alloc = 0;
@@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,
if (is_merged == include_reached)
array->items[array->nr++] = array->items[i];
else
- free_array_item(item);
+ free_ref_array_item(item);
}
clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
@@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,
strbuf_release(&err);
strbuf_release(&output);
- free_array_item(ref_item);
+ free_ref_array_item(ref_item);
}
static int parse_sorting_atom(const char *atom)
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..3883b9dc62 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
struct ref_format *format);
/* Clear all memory allocated to ref_array */
void ref_array_clear(struct ref_array *array);
+/* Free a single item from a ref_array */
+void free_ref_array_item(struct ref_array_item *item);
/* Used to verify if the given format is correct and to parse out the used atoms */
int verify_ref_format(struct ref_format *format);
/* Sort the given ref_array as per the ref_sorting provided */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ test_when_finished "git -C forked checkout detached" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v12 1/6] branch: add --forked filter for --list mode
2026-06-03 9:04 ` [PATCH v12 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-05 13:48 ` Phillip Wood
2026-06-05 17:50 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-05 13:48 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Add a --forked option to "git branch" list mode that keeps only
> branches whose configured upstream matches <branch>. The argument
> can be a ref (e.g. "origin/main", "master") or a shell-style
> glob (e.g. "origin/*"). The option can be repeated to widen the
> filter.
Do we want to support a remote name as an alias for $remote/HEAD to
match "git checkout -b $remote"?
> Because it is a filter on list mode, --forked composes with the
> existing list-mode filters, so
>
> git branch --merged origin/main --forked 'origin/*'
>
> lists branches forked from origin that have already been
> integrated into origin/main, and --no-merged inverts the question.
Nice
> This is the building block for --prune-merged, which deletes the
> listed branches once they have landed on their upstream.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 7 ++
> builtin/branch.c | 147 +++++++++++++++++++++++++++++++++-
> ref-filter.c | 10 +--
> ref-filter.h | 2 +
> t/t3200-branch.sh | 92 +++++++++++++++++++++
> 5 files changed, 249 insertions(+), 9 deletions(-)
>
> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index c0afddc424..8002d7f38c 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
> [--merged [<commit>]] [--no-merged [<commit>]]
> [--contains [<commit>]] [--no-contains [<commit>]]
> [--points-at <object>] [--format=<format>]
> + [(--forked <branch>)...]
Should this come before --format? I think it logically belongs with
--merged and --contains which also filter the output.
> [(-r|--remotes) | (-a|--all)]
> [--list] [<pattern>...]
> git branch [--track[=(direct|inherit)] | --no-track] [-f]
> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
>
> +`--forked <branch>`::
> + List only branches whose configured upstream matches
> + _<branch>_. The argument can be a ref (e.g. `origin/main`,
> + `master`) or a shell-style glob (e.g. `'origin/*'`). The
> + option can be repeated to widen the filter.
This is fine but do we want to add a sentence to the DESCRIPTION as well
where it talks about "--contains" and "--merged"?
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..12711b29cf 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -28,9 +28,10 @@
> #include "help.h"
> #include "advice.h"
> #include "commit-reach.h"
> +#include "wildmatch.h"
>
> static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
> + N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
> N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
> N_("git branch [<options>] [-l] [<pattern>...]"),
> N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
> @@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
> return strbuf_detach(&fmt, NULL);
> }
>
> +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams);
We try to avoid forward declarations unless they're really needed - can
we add the new functions up here instead?
> static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
> - struct ref_format *format, struct string_list *output)
> + struct ref_format *format, struct string_list *output,
> + const struct string_list *forked_upstreams)
> {
> int i;
> struct ref_array array;
> @@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
>
> filter_refs(&array, filter, filter->kind);
>
> + if (forked_upstreams->nr)
> + filter_array_by_forked(&array, forked_upstreams);
This gets a bit messy below where free elements when we filter "array".
It would be much nicer to do the filtering in apply_ref_filter() so that
we don't have to allocate those in the first place. I think it would
make it simpler to implement --prune-merged as collect_forked_set()
would become a call to filter_refs() and we could support --forked in
"git for-each-ref".
> +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
> +{
> + struct object_id oid;
> + char *full_ref = NULL;
> +
> + if (has_glob_specials(arg)) {
> + out->name = xstrdup(arg);
> + out->is_wildcard = 1;
> + return 0;
> + }
> +
> + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> + &full_ref, 0) == 1 &&
> + (starts_with(full_ref, "refs/heads/") ||
> + starts_with(full_ref, "refs/remotes/"))) {
> + out->name = xstrdup(short_upstream_name(full_ref));
I don't think abbreviating the refname here is a good idea as short
names are inherently ambiguous - in principle you could have a remote
tracking branch and a local branch with the same short name. It also
means we end up reconstructing the full name in a later patch, instead
we should just call short_upstream_name() where we need the abbreviated
name.
> +static int upstream_matches(const char *short_upstream,
> + const struct upstream_pattern *patterns,
> + size_t nr)
> +{
> + size_t i;
> +
> + for (i = 0; i < nr; i++) {
> + const struct upstream_pattern *p = &patterns[i];
> + if (p->is_wildcard) {
> + if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
> + return 1;
> + } else if (!strcmp(p->name, short_upstream)) {
> + return 1;
> + }
> + }
This is quadratic but maybe we can assume the user wont pass "--forked"
too many times. If this ever becomes a problem we could use an strset
for the exact matches and then we only need to loop over the wildmatch
patterns but we probably don't need to worry about that now.
> +static int branch_upstream_matches(const char *full_refname,
> + const struct upstream_pattern *patterns,
> + size_t nr_patterns)
> +{
> + const char *short_name;
> + struct branch *branch;
> + const char *upstream;
> +
> + if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> + return 0;
> + branch = branch_get(short_name);
> + if (!branch)
> + return 0;
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> + return upstream_matches(short_upstream_name(upstream),
This would be simpler if we matched on full names.
> +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + int i, kept = 0;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> +
> + for (i = 0; i < array->nr; i++) {
> + struct ref_array_item *item = array->items[i];
> + if (branch_upstream_matches(item->refname,
> + patterns, nr_patterns))
> + array->items[kept++] = item;
> + else
> + free_ref_array_item(item);
> + }
> + array->nr = kept;
As I said above this would be nicer if it was implemented in
apply_ref_filter().
> @@ -714,6 +847,7 @@ int cmd_branch(int argc,
> /* possible actions */
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> + struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
Personally I'd use a strvec here as we don't need the "util" member of
the string list but I'm probably biased as I don't really like the
string list api.
I like the idea of making this just another filter to "--list". The
basics of the implementation look reasonable - it should be straight
forward to match on full refs and move the relavent code into filter-refs.c
Thanks
Phillip
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -767,6 +901,8 @@ int cmd_branch(int argc,
> OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
> OPT_BOOL(0, "edit-description", &edit_description,
> N_("edit the description for the branch")),
> + OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> + N_("list local branches whose upstream matches <branch> (repeatable)")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -815,7 +951,8 @@ int cmd_branch(int argc,
> list = 1;
>
> if (filter.with_commit || filter.no_commit ||
> - filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
> + filter.reachable_from || filter.unreachable_from ||
> + filter.points_at.nr || forked_upstreams.nr)
> list = 1;
>
> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> @@ -880,7 +1017,8 @@ int cmd_branch(int argc,
> ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
> ref_sorting_set_sort_flags_all(
> sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
> - print_ref_list(&filter, sorting, &format, &output);
> + print_ref_list(&filter, sorting, &format, &output,
> + &forked_upstreams);
> print_columns(&output, colopts, NULL);
> string_list_clear(&output, 0);
> ref_sorting_release(sorting);
> @@ -1020,5 +1158,6 @@ int cmd_branch(int argc,
>
> out:
> string_list_clear(&sorting_options, 0);
> + string_list_clear(&forked_upstreams, 0);
> return ret;
> }
> diff --git a/ref-filter.c b/ref-filter.c
> index 1da4c0e60d..65e7bc6785 100644
> --- a/ref-filter.c
> +++ b/ref-filter.c
> @@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)
> }
>
> /* Free memory allocated for a ref_array_item */
> -static void free_array_item(struct ref_array_item *item)
> +void free_ref_array_item(struct ref_array_item *item)
> {
> free((char *)item->symref);
> if (item->value) {
> @@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)
>
> strbuf_release(&output);
> strbuf_release(&err);
> - free_array_item(item);
> + free_ref_array_item(item);
>
> /*
> * Increment the running count of refs that match the filter. If
> @@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)
> int i;
>
> for (i = 0; i < array->nr; i++)
> - free_array_item(array->items[i]);
> + free_ref_array_item(array->items[i]);
> FREE_AND_NULL(array->items);
> array->nr = array->alloc = 0;
>
> @@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,
> if (is_merged == include_reached)
> array->items[array->nr++] = array->items[i];
> else
> - free_array_item(item);
> + free_ref_array_item(item);
> }
>
> clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
> @@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,
>
> strbuf_release(&err);
> strbuf_release(&output);
> - free_array_item(ref_item);
> + free_ref_array_item(ref_item);
> }
>
> static int parse_sorting_atom(const char *atom)
> diff --git a/ref-filter.h b/ref-filter.h
> index 120221b47f..3883b9dc62 100644
> --- a/ref-filter.h
> +++ b/ref-filter.h
> @@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
> struct ref_format *format);
> /* Clear all memory allocated to ref_array */
> void ref_array_clear(struct ref_array *array);
> +/* Free a single item from a ref_array */
> +void free_ref_array_item(struct ref_array_item *item);
> /* Used to verify if the given format is correct and to parse out the used atoms */
> int verify_ref_format(struct ref_format *format);
> /* Sort the given ref_array as per the ref_sorting provided */
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index e7829c2c4b..4e7deddc04 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
> test_cmp expect actual
> '
>
> +test_expect_success '--forked: setup' '
> + test_create_repo forked-upstream &&
> + test_commit -C forked-upstream base &&
> + git -C forked-upstream branch one base &&
> + git -C forked-upstream branch two base &&
> +
> + test_create_repo forked-other &&
> + test_commit -C forked-other other-base &&
> + git -C forked-other branch foreign other-base &&
> +
> + git clone forked-upstream forked &&
> + git -C forked remote add other ../forked-other &&
> + git -C forked fetch other &&
> + git -C forked branch local-base &&
> + git -C forked branch --track local-one origin/one &&
> + git -C forked branch --track local-two origin/two &&
> + git -C forked branch --track local-foreign other/foreign &&
> + git -C forked branch detached &&
> + git -C forked branch --track local-trunk local-base
> +'
> +
> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
> + git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <glob> filters by wildmatch' '
> + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <local-branch> matches branches with local upstream' '
> + git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
> + echo local-trunk >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked can be repeated to widen the filter' '
> + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-trunk
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
> + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked composes with --no-merged' '
> + test_when_finished "git -C forked checkout detached" &&
> + git -C forked checkout local-one &&
> + test_commit -C forked local-only &&
> + git -C forked branch --forked "origin/*" --no-merged origin/one \
> + --format="%(refname:short)" >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked rejects unknown branch/pattern' '
> + test_must_fail git -C forked branch --forked nope 2>err &&
> + test_grep "not a valid branch or pattern" err
> +'
> +
> +test_expect_success '--forked requires a value' '
> + test_must_fail git -C forked branch --forked 2>err &&
> + test_grep "requires a value" err
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v12 1/6] branch: add --forked filter for --list mode
2026-06-05 13:48 ` Phillip Wood
@ 2026-06-05 17:50 ` Harald Nordgren
0 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-05 17:50 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
Hi Phillip!
Great points all around, I will take a look at implementing them. I'll
respond here instead of for each specific message, and then include
comments as part of the next version.
> > Add a --forked option to "git branch" list mode that keeps only
> > branches whose configured upstream matches <branch>. The argument
> > can be a ref (e.g. "origin/main", "master") or a shell-style
> > glob (e.g. "origin/*"). The option can be repeated to widen the
> > filter.
>
> Do we want to support a remote name as an alias for $remote/HEAD to
> match "git checkout -b $remote"?
I have been going back and forth on this, and while I like the bare
remote, it made the implementation a lot easier after it was removed,
as the arguments from some of the others made sense to me.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
2026-06-03 9:04 ` [PATCH v12 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-05 13:49 ` Phillip Wood
2026-06-03 9:04 ` [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
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 <haraldnordgren@gmail.com>
---
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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-03 9:04 ` [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-05 13:49 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-05 13:49 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> 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 <haraldnordgren@gmail.com>
> ---
> 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)
We've already got two boolean parameters, lets replace those with an
"unsigned int flags" parameter rather than adding a third. That way we
can avoid having to comment each argument as you do in a later patch.
Thanks
Phillip
> {
> 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();
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
2026-06-03 9:04 ` [PATCH v12 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-03 9:04 ` [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-05 13:49 ` Phillip Wood
2026-06-03 9:04 ` [PATCH v12 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add no_head_fallback and dry_run flags to delete_branches() so a
bulk caller (the upcoming --prune-merged) can ask strictly about
merged-into-upstream without a silent fallback to HEAD, and
rehearse deletions with the same "Would delete branch ..." wording
as the live run. Existing callers pass 0 for both and keep current
behavior.
When no_head_fallback is set, head_rev stays NULL through to
branch_merged(), whose "merged to X but not yet merged to HEAD"
reminder otherwise compares against HEAD. For the bulk caller
every candidate is known to have an upstream, so HEAD is
irrelevant. Guard the block on head_rev so the NULL case skips
it instead of treating "NULL != reference_rev" as "diverges from
HEAD" and emitting a spurious warning.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller
2026-06-03 9:04 ` [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-05 13:49 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-05 13:49 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Add no_head_fallback and dry_run flags to delete_branches() so a
> bulk caller (the upcoming --prune-merged) can ask strictly about
> merged-into-upstream without a silent fallback to HEAD, and
> rehearse deletions with the same "Would delete branch ..." wording
> as the live run. Existing callers pass 0 for both and keep current
> behavior.
>
> When no_head_fallback is set, head_rev stays NULL through to
> branch_merged(), whose "merged to X but not yet merged to HEAD"
> reminder otherwise compares against HEAD. For the bulk caller
> every candidate is known to have an upstream, so HEAD is
> irrelevant. Guard the block on head_rev so the NULL case skips
> it instead of treating "NULL != reference_rev" as "diverges from
> HEAD" and emitting a spurious warning.
Same comment as the last patch - use a flags argument rather than lots
of individual booleans that make the call sites hard to read.
Thanks
Phillip
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> 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();
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v12 4/6] branch: add --prune-merged <branch>
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-03 9:04 ` [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-05 13:50 ` Phillip Wood
2026-06-03 9:04 ` [PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
restricted to those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from local refs; nothing is fetched. Users
who want fresh upstream refs run "git fetch" first.
Three classes of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally (its
disappearance is not, on its own, evidence of integration);
* any branch whose push destination equals its upstream
(<branch>@{push} == <branch>@{upstream}). Such a branch
cannot be distinguished from a freshly pulled trunk that
just looks "fully merged", e.g. local "main" tracking and
pushing to "origin/main" right after a pull. Only branches
that push somewhere other than their upstream (typically
topics in a fork-based workflow) are treated as candidates.
Deletion goes through the existing delete_branches() in warn-only
mode and with the HEAD-fallback disabled: a branch that is not
yet fully merged to its upstream is reported as a one-line warning
and skipped, so a single un-mergeable topic does not abort the
whole sweep. We only act on upstream-merged status.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 23 +++++
builtin/branch.c | 117 +++++++++++++++++++--
t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
3 files changed, 318 insertions(+), 10 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 8002d7f38c..f7942fcd7d 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch (--prune-merged <branch>)...
DESCRIPTION
-----------
@@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
`master`) or a shell-style glob (e.g. `'origin/*'`). The
option can be repeated to widen the filter.
+`--prune-merged <branch>`::
+ Delete the local branches that `--forked` would list for the
+ same _<branch>_, but only those whose tip is reachable from
+ their configured upstream. In other words, the work on the
+ branch has already landed on the upstream it tracks, so the
+ local copy is no longer needed. May be given more than once to
+ union the matches; positional arguments are not accepted.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 09afdd9257..736480b002 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] (--prune-merged <branch>)..."),
NULL
};
@@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,
return 0;
}
-static int branch_upstream_matches(const char *full_refname,
+static int branch_upstream_matches(const char *short_branch_name,
const struct upstream_pattern *patterns,
size_t nr_patterns)
{
- const char *short_name;
- struct branch *branch;
+ struct branch *branch = branch_get(short_branch_name);
const char *upstream;
- if (!skip_prefix(full_refname, "refs/heads/", &short_name))
- return 0;
- branch = branch_get(short_name);
if (!branch)
return 0;
upstream = branch_get_upstream(branch, NULL);
@@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,
for (i = 0; i < array->nr; i++) {
struct ref_array_item *item = array->items[i];
- if (branch_upstream_matches(item->refname,
- patterns, nr_patterns))
+ const char *short_name;
+ if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
+ branch_upstream_matches(short_name, patterns, nr_patterns))
array->items[kept++] = item;
else
free_ref_array_item(item);
@@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,
upstream_pattern_list_clear(patterns, nr_patterns);
}
+struct forked_cb {
+ const struct upstream_pattern *patterns;
+ size_t nr_patterns;
+ struct string_list *out;
+};
+
+static int collect_forked_branch(const struct reference *ref, void *cb_data)
+{
+ struct forked_cb *cb = cb_data;
+
+ if (ref->flags & REF_ISSYMREF)
+ return 0;
+ if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
+ string_list_append(cb->out, ref->name);
+ return 0;
+}
+
+static void collect_forked_set(const struct string_list *upstreams,
+ struct string_list *out)
+{
+ struct upstream_pattern *patterns = NULL;
+ size_t nr_patterns = 0;
+ struct forked_cb cb;
+
+ parse_forked_args(upstreams, &patterns, &nr_patterns);
+ cb.patterns = patterns;
+ cb.nr_patterns = nr_patterns;
+ cb.out = out;
+
+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
+ collect_forked_branch, &cb);
+
+ string_list_sort(out);
+
+ upstream_pattern_list_clear(patterns, nr_patterns);
+}
+
+static int prune_merged_branches(const struct string_list *upstreams,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct string_list candidates = STRING_LIST_INIT_DUP;
+ struct strvec deletable = STRVEC_INIT;
+ struct string_list_item *item;
+ int ret = 0;
+
+ if (!upstreams->nr)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ collect_forked_set(upstreams, &candidates);
+
+ for_each_string_list_item(item, &candidates) {
+ const char *short_name = item->string;
+ struct branch *branch = branch_get(short_name);
+ const char *upstream, *push;
+ struct strbuf full = STRBUF_INIT;
+ int skip;
+
+ strbuf_addf(&full, "refs/heads/%s", short_name);
+ skip = !!branch_checked_out(full.buf);
+ strbuf_release(&full);
+ if (skip)
+ continue;
+
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
+ push = branch ? branch_get_push(branch, NULL) : NULL;
+ if (!push || !strcmp(push, upstream))
+ continue;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ 0, /* force */
+ FILTER_REFS_BRANCHES,
+ quiet,
+ 1, /* warn_only */
+ 1, /* no_head_fallback */
+ 0 /* dry_run */);
+
+ strvec_clear(&deletable);
+ string_list_clear(&candidates, 0);
+ return ret;
+}
+
static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
static int edit_branch_description(const char *branch_name)
@@ -866,6 +952,7 @@ int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
+ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -921,6 +1008,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
N_("list local branches whose upstream matches <branch> (repeatable)")),
+ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
+ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
OPT_MERGED(&filter, N_("print only branches that are merged")),
OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -965,7 +1054,8 @@ int cmd_branch(int argc,
0);
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
+ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -975,7 +1065,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!prune_merged_upstreams.nr;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
ret = delete_branches(argc, argv, delete > 1, filter.kind,
quiet, 0, 0, 0);
goto out;
+ } else if (prune_merged_upstreams.nr) {
+ if (argc)
+ die(_("--prune-merged does not take positional arguments; "
+ "repeat --prune-merged for each <branch>"));
+ ret = prune_merged_branches(&prune_merged_upstreams, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
@@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
out:
string_list_clear(&sorting_options, 0);
string_list_clear(&forked_upstreams, 0);
+ string_list_clear(&prune_merged_upstreams, 0);
return ret;
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..beb86987ad 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires a value' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "requires a value" err
+'
+
+test_expect_success '--prune-merged rejects positional arguments' '
+ test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
+ test_grep "does not take positional arguments" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v12 4/6] branch: add --prune-merged <branch>
2026-06-03 9:04 ` [PATCH v12 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-05 13:50 ` Phillip Wood
2026-06-05 15:04 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-05 13:50 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> git branch --prune-merged <branch>...
I'm not sure that synopsis is correct anymore as you need to repeat
"--prune-merged". As --prune-merged now takes an argument there is no
reason to forbid positional arguments so I think we should support
git branch --prune-merged origin/master 'feature*'
to delete all the branches beginning with "feature" that have the
upstream "origin/master" and have been merged.
I wonder about the name - the other options that delete branches are
called "delete", not "prune". Also "--prune-merged" does not delete the
branches listed by "--merged" so maybe "--delete-forked" would be better?
I've not commented in detail on the code as it will need to change a bit
once we match on full refnames and do the filtering in
apply_ref_filter() but I think the basics are sound.
I'll stop here - I did quickly scan the next two patches and they both
looked like sensible ideas.
Thanks
Phillip
> deletes the local branches that "--forked <branch>" would list,
> restricted to those whose tip is reachable from their configured
> upstream: the work has already landed on the upstream they track,
> so the local copy is no longer needed.
>
> Reachability is read from local refs; nothing is fetched. Users
> who want fresh upstream refs run "git fetch" first.
>
> Three classes of branches are spared:
>
> * any branch checked out in any worktree;
> * any branch whose upstream no longer resolves locally (its
> disappearance is not, on its own, evidence of integration);
> * any branch whose push destination equals its upstream
> (<branch>@{push} == <branch>@{upstream}). Such a branch
> cannot be distinguished from a freshly pulled trunk that
> just looks "fully merged", e.g. local "main" tracking and
> pushing to "origin/main" right after a pull. Only branches
> that push somewhere other than their upstream (typically
> topics in a fork-based workflow) are treated as candidates.
>
> Deletion goes through the existing delete_branches() in warn-only
> mode and with the HEAD-fallback disabled: a branch that is not
> yet fully merged to its upstream is reported as a one-line warning
> and skipped, so a single un-mergeable topic does not abort the
> whole sweep. We only act on upstream-merged status.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 23 +++++
> builtin/branch.c | 117 +++++++++++++++++++--
> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
> 3 files changed, 318 insertions(+), 10 deletions(-)
>
> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 8002d7f38c..f7942fcd7d 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
> git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> +git branch (--prune-merged <branch>)...
>
> DESCRIPTION
> -----------
> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
> `master`) or a shell-style glob (e.g. `'origin/*'`). The
> option can be repeated to widen the filter.
>
> +`--prune-merged <branch>`::
> + Delete the local branches that `--forked` would list for the
> + same _<branch>_, but only those whose tip is reachable from
> + their configured upstream. In other words, the work on the
> + branch has already landed on the upstream it tracks, so the
> + local copy is no longer needed. May be given more than once to
> + union the matches; positional arguments are not accepted.
> ++
> +Reachability is checked against whatever the upstream refs say
> +locally; nothing is fetched. Run `git fetch` first if you want
> +the upstream refs refreshed.
> ++
> +A branch is left alone if any of the following holds:
> +its upstream no longer resolves locally; it is checked out in any
> +worktree; or its push destination (`<branch>@{push}`) equals its
> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +from a freshly pulled trunk that just looks "fully merged".
> ++
> +Branches refused by the "fully merged" safety check are listed as
> +warnings and skipped; pass them to `git branch -D` explicitly if
> +you want them gone.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 09afdd9257..736480b002 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
> N_("git branch [<options>] [-r | -a] [--points-at]"),
> N_("git branch [<options>] [-r | -a] [--format]"),
> + N_("git branch [<options>] (--prune-merged <branch>)..."),
> NULL
> };
>
> @@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,
> return 0;
> }
>
> -static int branch_upstream_matches(const char *full_refname,
> +static int branch_upstream_matches(const char *short_branch_name,
> const struct upstream_pattern *patterns,
> size_t nr_patterns)
> {
> - const char *short_name;
> - struct branch *branch;
> + struct branch *branch = branch_get(short_branch_name);
> const char *upstream;
>
> - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> - return 0;
> - branch = branch_get(short_name);
> if (!branch)
> return 0;
> upstream = branch_get_upstream(branch, NULL);
> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,
>
> for (i = 0; i < array->nr; i++) {
> struct ref_array_item *item = array->items[i];
> - if (branch_upstream_matches(item->refname,
> - patterns, nr_patterns))
> + const char *short_name;
> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
> + branch_upstream_matches(short_name, patterns, nr_patterns))
> array->items[kept++] = item;
> else
> free_ref_array_item(item);
> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,
> upstream_pattern_list_clear(patterns, nr_patterns);
> }
>
> +struct forked_cb {
> + const struct upstream_pattern *patterns;
> + size_t nr_patterns;
> + struct string_list *out;
> +};
> +
> +static int collect_forked_branch(const struct reference *ref, void *cb_data)
> +{
> + struct forked_cb *cb = cb_data;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> + if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
> + string_list_append(cb->out, ref->name);
> + return 0;
> +}
> +
> +static void collect_forked_set(const struct string_list *upstreams,
> + struct string_list *out)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + struct forked_cb cb;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> + cb.patterns = patterns;
> + cb.nr_patterns = nr_patterns;
> + cb.out = out;
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> + string_list_sort(out);
> +
> + upstream_pattern_list_clear(patterns, nr_patterns);
> +}
> +
> +static int prune_merged_branches(const struct string_list *upstreams,
> + int quiet)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct string_list_item *item;
> + int ret = 0;
> +
> + if (!upstreams->nr)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(upstreams, &candidates);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + struct branch *branch = branch_get(short_name);
> + const char *upstream, *push;
> + struct strbuf full = STRBUF_INIT;
> + int skip;
> +
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + skip = !!branch_checked_out(full.buf);
> + strbuf_release(&full);
> + if (skip)
> + continue;
> +
> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> + if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> + push = branch ? branch_get_push(branch, NULL) : NULL;
> + if (!push || !strcmp(push, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 0, /* force */
> + FILTER_REFS_BRANCHES,
> + quiet,
> + 1, /* warn_only */
> + 1, /* no_head_fallback */
> + 0 /* dry_run */);
> +
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> + return ret;
> +}
> +
> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
>
> static int edit_branch_description(const char *branch_name)
> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
> N_("edit the description for the branch")),
> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> N_("list local branches whose upstream matches <branch> (repeatable)")),
> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
> + N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
> OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
> OPT_MERGED(&filter, N_("print only branches that are merged")),
> OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,
> 0);
>
> if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && argc == 0)
> + !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
> + argc == 0)
> list = 1;
>
> if (filter.with_commit || filter.no_commit ||
> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
>
> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream;
> + !!unset_upstream + !!prune_merged_upstreams.nr;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
>
> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
> ret = delete_branches(argc, argv, delete > 1, filter.kind,
> quiet, 0, 0, 0);
> goto out;
> + } else if (prune_merged_upstreams.nr) {
> + if (argc)
> + die(_("--prune-merged does not take positional arguments; "
> + "repeat --prune-merged for each <branch>"));
> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
> + goto out;
> } else if (show_current) {
> print_current_branch_name();
> ret = 0;
> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
> out:
> string_list_clear(&sorting_options, 0);
> string_list_clear(&forked_upstreams, 0);
> + string_list_clear(&prune_merged_upstreams, 0);
> return ret;
> }
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..beb86987ad 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
> test_grep "requires a value" err
> '
>
> +test_expect_success '--prune-merged: setup' '
> + test_create_repo pm-upstream &&
> + test_commit -C pm-upstream base &&
> + git -C pm-upstream checkout -b next &&
> + test_commit -C pm-upstream one-commit &&
> + test_commit -C pm-upstream two-commit &&
> + git -C pm-upstream branch one HEAD~ &&
> + git -C pm-upstream branch two HEAD &&
> + git -C pm-upstream branch wip main &&
> + git -C pm-upstream checkout main &&
> + test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> + test_when_finished "rm -rf pm-merged" &&
> + git clone pm-upstream pm-merged &&
> + git -C pm-merged remote add fork ../pm-fork &&
> + test_config -C pm-merged remote.pushDefault fork &&
> + test_config -C pm-merged push.default current &&
> + git -C pm-merged branch one one-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next one &&
> + git -C pm-merged branch two two-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next two &&
> +
> + git -C pm-merged branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a literal upstream' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
> + git -C pm-literal remote add fork ../pm-fork &&
> + test_config -C pm-literal remote.pushDefault fork &&
> + test_config -C pm-literal push.default current &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> + git -C pm-union remote add fork ../pm-fork &&
> + test_config -C pm-union remote.pushDefault fork &&
> + test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> + git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
> +
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> + git clone pm-upstream pm-local &&
> + git -C pm-local remote add fork ../pm-fork &&
> + test_config -C pm-local remote.pushDefault fork &&
> + test_config -C pm-local push.default current &&
> + git -C pm-local checkout -b trunk &&
> + git -C pm-local branch one one-commit &&
> + git -C pm-local branch --set-upstream-to=trunk one &&
> + git -C pm-local merge --ff-only one-commit &&
> +
> + git -C pm-local branch --prune-merged trunk &&
> +
> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + git -C pm-unmerged remote add fork ../pm-fork &&
> + test_config -C pm-unmerged remote.pushDefault fork &&
> + test_config -C pm-unmerged push.default current &&
> + git -C pm-unmerged checkout -b wip origin/wip &&
> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-unmerged local-only &&
> + git -C pm-unmerged checkout - &&
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> + test_when_finished "rm -rf pm-nohead" &&
> + git clone pm-upstream pm-nohead &&
> + git -C pm-nohead remote add fork ../pm-fork &&
> + test_config -C pm-nohead remote.pushDefault fork &&
> + test_config -C pm-nohead push.default current &&
> + git -C pm-nohead branch topic one-commit &&
> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> + test_grep ! "not yet merged to HEAD" err &&
> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> + test_when_finished "rm -rf pm-upstream-gone" &&
> + git clone pm-upstream pm-upstream-gone &&
> + git -C pm-upstream-gone remote add fork ../pm-fork &&
> + test_config -C pm-upstream-gone remote.pushDefault fork &&
> + test_config -C pm-upstream-gone push.default current &&
> + git -C pm-upstream-gone branch one one-commit &&
> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> + test_when_finished "rm -rf pm-head" &&
> + git clone pm-upstream pm-head &&
> + git -C pm-head remote add fork ../pm-fork &&
> + test_config -C pm-head remote.pushDefault fork &&
> + test_config -C pm-head push.default current &&
> + git -C pm-head checkout -b one one-commit &&
> + git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-head branch --prune-merged "origin/*" &&
> +
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> + test_when_finished "rm -rf pm-push-eq" &&
> + git clone pm-upstream pm-push-eq &&
> + git -C pm-push-eq checkout --detach &&
> +
> + git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> + test_when_finished "rm -rf pm-push-branch" &&
> + git clone pm-upstream pm-push-branch &&
> + git -C pm-push-branch remote add fork ../pm-fork &&
> + test_config -C pm-push-branch remote.pushDefault fork &&
> + test_config -C pm-push-branch push.default current &&
> + test_config -C pm-push-branch branch.main.pushRemote origin &&
> + git -C pm-push-branch checkout --detach &&
> +
> + git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> + test_when_finished "rm -rf pm-push-diff" &&
> + git clone pm-upstream pm-push-diff &&
> + git -C pm-push-diff remote add fork ../pm-fork &&
> + test_config -C pm-push-diff remote.pushDefault fork &&
> + test_config -C pm-push-diff push.default current &&
> + git -C pm-push-diff branch topic one-commit &&
> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> + git -C pm-push-diff checkout --detach &&
> +
> + git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires a value' '
> + test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "requires a value" err
> +'
> +
> +test_expect_success '--prune-merged rejects positional arguments' '
> + test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
> + test_grep "does not take positional arguments" err
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v12 4/6] branch: add --prune-merged <branch>
2026-06-05 13:50 ` Phillip Wood
@ 2026-06-05 15:04 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-05 15:04 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
On 05/06/2026 14:50, Phillip Wood wrote:
>
> I wonder about the name - the other options that delete branches are
> called "delete", not "prune". Also "--prune-merged" does not delete the
> branches listed by "--merged" so maybe "--delete-forked" would be better?
"delete-forked" doesn't capture the fact the branch has been merged
though - I wonder if anyone has a better idea
Thanks
Phillip
> I've not commented in detail on the code as it will need to change a bit
> once we match on full refnames and do the filtering in
> apply_ref_filter() but I think the basics are sound.
>
> I'll stop here - I did quickly scan the next two patches and they both
> looked like sensible ideas.
>
> Thanks
>
> Phillip
>
>> deletes the local branches that "--forked <branch>" would list,
>> restricted to those whose tip is reachable from their configured
>> upstream: the work has already landed on the upstream they track,
>> so the local copy is no longer needed.
>>
>> Reachability is read from local refs; nothing is fetched. Users
>> who want fresh upstream refs run "git fetch" first.
>>
>> Three classes of branches are spared:
>>
>> * any branch checked out in any worktree;
>> * any branch whose upstream no longer resolves locally (its
>> disappearance is not, on its own, evidence of integration);
>> * any branch whose push destination equals its upstream
>> (<branch>@{push} == <branch>@{upstream}). Such a branch
>> cannot be distinguished from a freshly pulled trunk that
>> just looks "fully merged", e.g. local "main" tracking and
>> pushing to "origin/main" right after a pull. Only branches
>> that push somewhere other than their upstream (typically
>> topics in a fork-based workflow) are treated as candidates.
>>
>> Deletion goes through the existing delete_branches() in warn-only
>> mode and with the HEAD-fallback disabled: a branch that is not
>> yet fully merged to its upstream is reported as a one-line warning
>> and skipped, so a single un-mergeable topic does not abort the
>> whole sweep. We only act on upstream-merged status.
>>
>> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>> ---
>> Documentation/git-branch.adoc | 23 +++++
>> builtin/branch.c | 117 +++++++++++++++++++--
>> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
>> 3 files changed, 318 insertions(+), 10 deletions(-)
>>
>> diff --git a/Documentation/git-branch.adoc b/Documentation/git-
>> branch.adoc
>> index 8002d7f38c..f7942fcd7d 100644
>> --- a/Documentation/git-branch.adoc
>> +++ b/Documentation/git-branch.adoc
>> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
>> git branch (-c|-C) [<old-branch>] <new-branch>
>> git branch (-d|-D) [-r] <branch-name>...
>> git branch --edit-description [<branch-name>]
>> +git branch (--prune-merged <branch>)...
>> DESCRIPTION
>> -----------
>> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
>> `master`) or a shell-style glob (e.g. `'origin/*'`). The
>> option can be repeated to widen the filter.
>> +`--prune-merged <branch>`::
>> + Delete the local branches that `--forked` would list for the
>> + same _<branch>_, but only those whose tip is reachable from
>> + their configured upstream. In other words, the work on the
>> + branch has already landed on the upstream it tracks, so the
>> + local copy is no longer needed. May be given more than once to
>> + union the matches; positional arguments are not accepted.
>> ++
>> +Reachability is checked against whatever the upstream refs say
>> +locally; nothing is fetched. Run `git fetch` first if you want
>> +the upstream refs refreshed.
>> ++
>> +A branch is left alone if any of the following holds:
>> +its upstream no longer resolves locally; it is checked out in any
>> +worktree; or its push destination (`<branch>@{push}`) equals its
>> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
>> +from a freshly pulled trunk that just looks "fully merged".
>> ++
>> +Branches refused by the "fully merged" safety check are listed as
>> +warnings and skipped; pass them to `git branch -D` explicitly if
>> +you want them gone.
>> +
>> `-v`::
>> `-vv`::
>> `--verbose`::
>> diff --git a/builtin/branch.c b/builtin/branch.c
>> index 09afdd9257..736480b002 100644
>> --- a/builtin/branch.c
>> +++ b/builtin/branch.c
>> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
>> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
>> N_("git branch [<options>] [-r | -a] [--points-at]"),
>> N_("git branch [<options>] [-r | -a] [--format]"),
>> + N_("git branch [<options>] (--prune-merged <branch>)..."),
>> NULL
>> };
>> @@ -782,17 +783,13 @@ static int upstream_matches(const char
>> *short_upstream,
>> return 0;
>> }
>> -static int branch_upstream_matches(const char *full_refname,
>> +static int branch_upstream_matches(const char *short_branch_name,
>> const struct upstream_pattern *patterns,
>> size_t nr_patterns)
>> {
>> - const char *short_name;
>> - struct branch *branch;
>> + struct branch *branch = branch_get(short_branch_name);
>> const char *upstream;
>> - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
>> - return 0;
>> - branch = branch_get(short_name);
>> if (!branch)
>> return 0;
>> upstream = branch_get_upstream(branch, NULL);
>> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct
>> ref_array *array,
>> for (i = 0; i < array->nr; i++) {
>> struct ref_array_item *item = array->items[i];
>> - if (branch_upstream_matches(item->refname,
>> - patterns, nr_patterns))
>> + const char *short_name;
>> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
>> + branch_upstream_matches(short_name, patterns, nr_patterns))
>> array->items[kept++] = item;
>> else
>> free_ref_array_item(item);
>> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct
>> ref_array *array,
>> upstream_pattern_list_clear(patterns, nr_patterns);
>> }
>> +struct forked_cb {
>> + const struct upstream_pattern *patterns;
>> + size_t nr_patterns;
>> + struct string_list *out;
>> +};
>> +
>> +static int collect_forked_branch(const struct reference *ref, void
>> *cb_data)
>> +{
>> + struct forked_cb *cb = cb_data;
>> +
>> + if (ref->flags & REF_ISSYMREF)
>> + return 0;
>> + if (branch_upstream_matches(ref->name, cb->patterns, cb-
>> >nr_patterns))
>> + string_list_append(cb->out, ref->name);
>> + return 0;
>> +}
>> +
>> +static void collect_forked_set(const struct string_list *upstreams,
>> + struct string_list *out)
>> +{
>> + struct upstream_pattern *patterns = NULL;
>> + size_t nr_patterns = 0;
>> + struct forked_cb cb;
>> +
>> + parse_forked_args(upstreams, &patterns, &nr_patterns);
>> + cb.patterns = patterns;
>> + cb.nr_patterns = nr_patterns;
>> + cb.out = out;
>> +
>> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
>> + collect_forked_branch, &cb);
>> +
>> + string_list_sort(out);
>> +
>> + upstream_pattern_list_clear(patterns, nr_patterns);
>> +}
>> +
>> +static int prune_merged_branches(const struct string_list *upstreams,
>> + int quiet)
>> +{
>> + struct ref_store *refs = get_main_ref_store(the_repository);
>> + struct string_list candidates = STRING_LIST_INIT_DUP;
>> + struct strvec deletable = STRVEC_INIT;
>> + struct string_list_item *item;
>> + int ret = 0;
>> +
>> + if (!upstreams->nr)
>> + die(_("--prune-merged requires at least one <branch>"));
>> +
>> + collect_forked_set(upstreams, &candidates);
>> +
>> + for_each_string_list_item(item, &candidates) {
>> + const char *short_name = item->string;
>> + struct branch *branch = branch_get(short_name);
>> + const char *upstream, *push;
>> + struct strbuf full = STRBUF_INIT;
>> + int skip;
>> +
>> + strbuf_addf(&full, "refs/heads/%s", short_name);
>> + skip = !!branch_checked_out(full.buf);
>> + strbuf_release(&full);
>> + if (skip)
>> + continue;
>> +
>> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
>> + if (!upstream || !refs_ref_exists(refs, upstream))
>> + continue;
>> + push = branch ? branch_get_push(branch, NULL) : NULL;
>> + if (!push || !strcmp(push, upstream))
>> + continue;
>> +
>> + strvec_push(&deletable, short_name);
>> + }
>> +
>> + if (deletable.nr)
>> + ret = delete_branches(deletable.nr, deletable.v,
>> + 0, /* force */
>> + FILTER_REFS_BRANCHES,
>> + quiet,
>> + 1, /* warn_only */
>> + 1, /* no_head_fallback */
>> + 0 /* dry_run */);
>> +
>> + strvec_clear(&deletable);
>> + string_list_clear(&candidates, 0);
>> + return ret;
>> +}
>> +
>> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
>> static int edit_branch_description(const char *branch_name)
>> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
>> int delete = 0, rename = 0, copy = 0, list = 0,
>> unset_upstream = 0, show_current = 0, edit_description = 0;
>> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
>> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
>> const char *new_upstream = NULL;
>> int noncreate_actions = 0;
>> /* possible options */
>> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
>> N_("edit the description for the branch")),
>> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
>> N_("list local branches whose upstream matches <branch>
>> (repeatable)")),
>> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams,
>> N_("branch"),
>> + N_("delete local branches whose upstream matches <branch>
>> and is merged (repeatable)")),
>> OPT__FORCE(&force, N_("force creation, move/rename,
>> deletion"), PARSE_OPT_NOCOMPLETE),
>> OPT_MERGED(&filter, N_("print only branches that are merged")),
>> OPT_NO_MERGED(&filter, N_("print only branches that are not
>> merged")),
>> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,
>> 0);
>> if (!delete && !rename && !copy && !edit_description && !
>> new_upstream &&
>> - !show_current && !unset_upstream && argc == 0)
>> + !show_current && !unset_upstream && !
>> prune_merged_upstreams.nr &&
>> + argc == 0)
>> list = 1;
>> if (filter.with_commit || filter.no_commit ||
>> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
>> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
>> !!show_current + !!list + !!edit_description +
>> - !!unset_upstream;
>> + !!unset_upstream + !!prune_merged_upstreams.nr;
>> if (noncreate_actions > 1)
>> usage_with_options(builtin_branch_usage, options);
>> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
>> ret = delete_branches(argc, argv, delete > 1, filter.kind,
>> quiet, 0, 0, 0);
>> goto out;
>> + } else if (prune_merged_upstreams.nr) {
>> + if (argc)
>> + die(_("--prune-merged does not take positional arguments; "
>> + "repeat --prune-merged for each <branch>"));
>> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
>> + goto out;
>> } else if (show_current) {
>> print_current_branch_name();
>> ret = 0;
>> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
>> out:
>> string_list_clear(&sorting_options, 0);
>> string_list_clear(&forked_upstreams, 0);
>> + string_list_clear(&prune_merged_upstreams, 0);
>> return ret;
>> }
>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
>> index 4e7deddc04..beb86987ad 100755
>> --- a/t/t3200-branch.sh
>> +++ b/t/t3200-branch.sh
>> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
>> test_grep "requires a value" err
>> '
>> +test_expect_success '--prune-merged: setup' '
>> + test_create_repo pm-upstream &&
>> + test_commit -C pm-upstream base &&
>> + git -C pm-upstream checkout -b next &&
>> + test_commit -C pm-upstream one-commit &&
>> + test_commit -C pm-upstream two-commit &&
>> + git -C pm-upstream branch one HEAD~ &&
>> + git -C pm-upstream branch two HEAD &&
>> + git -C pm-upstream branch wip main &&
>> + git -C pm-upstream checkout main &&
>> + test_create_repo pm-fork
>> +'
>> +
>> +test_expect_success '--prune-merged deletes branches integrated into
>> upstream' '
>> + test_when_finished "rm -rf pm-merged" &&
>> + git clone pm-upstream pm-merged &&
>> + git -C pm-merged remote add fork ../pm-fork &&
>> + test_config -C pm-merged remote.pushDefault fork &&
>> + test_config -C pm-merged push.default current &&
>> + git -C pm-merged branch one one-commit &&
>> + git -C pm-merged branch --set-upstream-to=origin/next one &&
>> + git -C pm-merged branch two two-commit &&
>> + git -C pm-merged branch --set-upstream-to=origin/next two &&
>> +
>> + git -C pm-merged branch --prune-merged "origin/*" &&
>> +
>> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
>> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a literal upstream' '
>> + test_when_finished "rm -rf pm-literal" &&
>> + git clone pm-upstream pm-literal &&
>> + git -C pm-literal remote add fork ../pm-fork &&
>> + test_config -C pm-literal remote.pushDefault fork &&
>> + test_config -C pm-literal push.default current &&
>> + git -C pm-literal branch one one-commit &&
>> + git -C pm-literal branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-literal branch --prune-merged origin/next &&
>> +
>> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged unions multiple <branch>
>> arguments' '
>> + test_when_finished "rm -rf pm-union" &&
>> + git clone pm-upstream pm-union &&
>> + git -C pm-union remote add fork ../pm-fork &&
>> + test_config -C pm-union remote.pushDefault fork &&
>> + test_config -C pm-union push.default current &&
>> + git -C pm-union branch one one-commit &&
>> + git -C pm-union branch --set-upstream-to=origin/next one &&
>> + git -C pm-union branch two base &&
>> + git -C pm-union branch --set-upstream-to=origin/main two &&
>> + git -C pm-union checkout --detach &&
>> +
>> + git -C pm-union branch --prune-merged origin/next --prune-merged
>> origin/main &&
>> +
>> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
>> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a local upstream' '
>> + test_when_finished "rm -rf pm-local" &&
>> + git clone pm-upstream pm-local &&
>> + git -C pm-local remote add fork ../pm-fork &&
>> + test_config -C pm-local remote.pushDefault fork &&
>> + test_config -C pm-local push.default current &&
>> + git -C pm-local checkout -b trunk &&
>> + git -C pm-local branch one one-commit &&
>> + git -C pm-local branch --set-upstream-to=trunk one &&
>> + git -C pm-local merge --ff-only one-commit &&
>> +
>> + git -C pm-local branch --prune-merged trunk &&
>> +
>> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged warns instead of erroring on un-
>> integrated commits' '
>> + test_when_finished "rm -rf pm-unmerged" &&
>> + git clone pm-upstream pm-unmerged &&
>> + git -C pm-unmerged remote add fork ../pm-fork &&
>> + test_config -C pm-unmerged remote.pushDefault fork &&
>> + test_config -C pm-unmerged push.default current &&
>> + git -C pm-unmerged checkout -b wip origin/wip &&
>> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
>> + test_commit -C pm-unmerged local-only &&
>> + git -C pm-unmerged checkout - &&
>> +
>> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
>> + test_grep "not fully merged" err &&
>> + test_grep ! "If you are sure you want to delete it" err &&
>> + git -C pm-unmerged rev-parse --verify refs/heads/wip
>> +'
>> +
>> +test_expect_success '--prune-merged is silent about not-merged-to-
>> HEAD' '
>> + test_when_finished "rm -rf pm-nohead" &&
>> + git clone pm-upstream pm-nohead &&
>> + git -C pm-nohead remote add fork ../pm-fork &&
>> + test_config -C pm-nohead remote.pushDefault fork &&
>> + test_config -C pm-nohead push.default current &&
>> + git -C pm-nohead branch topic one-commit &&
>> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
>> +
>> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
>> +
>> + test_grep ! "not yet merged to HEAD" err &&
>> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
>> +'
>> +
>> +test_expect_success '--prune-merged skips branches whose upstream is
>> gone' '
>> + test_when_finished "rm -rf pm-upstream-gone" &&
>> + git clone pm-upstream pm-upstream-gone &&
>> + git -C pm-upstream-gone remote add fork ../pm-fork &&
>> + test_config -C pm-upstream-gone remote.pushDefault fork &&
>> + test_config -C pm-upstream-gone push.default current &&
>> + git -C pm-upstream-gone branch one one-commit &&
>> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
>> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged never deletes the checked-out
>> branch' '
>> + test_when_finished "rm -rf pm-head" &&
>> + git clone pm-upstream pm-head &&
>> + git -C pm-head remote add fork ../pm-fork &&
>> + test_config -C pm-head remote.pushDefault fork &&
>> + test_config -C pm-head push.default current &&
>> + git -C pm-head checkout -b one one-commit &&
>> + git -C pm-head branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-head branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-head rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged spares branches that push back to
>> their upstream' '
>> + test_when_finished "rm -rf pm-push-eq" &&
>> + git clone pm-upstream pm-push-eq &&
>> + git -C pm-push-eq checkout --detach &&
>> +
>> + git -C pm-push-eq branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-push-eq rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged spares a per-branch
>> pushRemote==upstream remote' '
>> + test_when_finished "rm -rf pm-push-branch" &&
>> + git clone pm-upstream pm-push-branch &&
>> + git -C pm-push-branch remote add fork ../pm-fork &&
>> + test_config -C pm-push-branch remote.pushDefault fork &&
>> + test_config -C pm-push-branch push.default current &&
>> + test_config -C pm-push-branch branch.main.pushRemote origin &&
>> + git -C pm-push-branch checkout --detach &&
>> +
>> + git -C pm-push-branch branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-push-branch rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged prunes when @{push} differs from
>> @{upstream}' '
>> + test_when_finished "rm -rf pm-push-diff" &&
>> + git clone pm-upstream pm-push-diff &&
>> + git -C pm-push-diff remote add fork ../pm-fork &&
>> + test_config -C pm-push-diff remote.pushDefault fork &&
>> + test_config -C pm-push-diff push.default current &&
>> + git -C pm-push-diff branch topic one-commit &&
>> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
>> + git -C pm-push-diff checkout --detach &&
>> +
>> + git -C pm-push-diff branch --prune-merged "origin/*" &&
>> +
>> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/
>> topic
>> +'
>> +
>> +test_expect_success '--prune-merged requires a value' '
>> + test_must_fail git -C forked branch --prune-merged 2>err &&
>> + test_grep "requires a value" err
>> +'
>> +
>> +test_expect_success '--prune-merged rejects positional arguments' '
>> + test_must_fail git -C forked branch --prune-merged origin/one
>> other/foreign 2>err &&
>> + test_grep "does not take positional arguments" err
>> +'
>> +
>> test_done
>
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-03 9:04 ` [PATCH v12 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-03 9:04 ` [PATCH v12 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.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 <haraldnordgren@gmail.com>
---
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.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ 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 (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.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.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v12 6/6] branch: add --dry-run for --prune-merged
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-03 9:04 ` [PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-03 9:04 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-03 9:04 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" 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 <haraldnordgren@gmail.com>
---
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) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch (--prune-merged <branch>)...
+git branch [--dry-run] (--prune-merged <branch>)...
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 <branch> (repeatable)")),
OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
+ OPT_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 <branch>"));
- 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v13 0/6] branch: prune-merged
2026-06-03 9:04 ` [PATCH v12 " Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-03 9:04 ` [PATCH v12 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (6 more replies)
6 siblings, 7 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its <branch> patterns as positional arguments
(e.g. git branch --prune-merged origin/main 'feature*') instead of
repeating the option.
Harald Nordgren (6):
branch: add --forked filter for --list mode
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 41 +++-
builtin/branch.c | 182 ++++++++++++---
ref-filter.c | 70 ++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 367 +++++++++++++++++++++++++++++++
6 files changed, 650 insertions(+), 27 deletions(-)
base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v13
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v13
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v12:
1: 8834c424fb ! 1: ccd07cff25 branch: add --forked filter for --list mode
@@ Metadata
## Commit message ##
branch: add --forked filter for --list mode
- Add a --forked option to "git branch" list mode that keeps only
+ Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
- can be a ref (e.g. "origin/main", "master") or a shell-style
- glob (e.g. "origin/*"). The option can be repeated to widen the
- filter.
+ can be a ref (e.g. "origin/main", "master") or a shell glob
+ (e.g. "origin/*"), and may be repeated to widen the filter.
- Because it is a filter on list mode, --forked composes with the
- existing list-mode filters, so
+ 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 have already been
- integrated into origin/main, and --no-merged inverts the question.
+ lists branches forked from origin that are already merged into
+ origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
@@ Commit message
## Documentation/git-branch.adoc ##
@@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]
+ [--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
- [--points-at <object>] [--format=<format>]
+ [(--forked <branch>)...]
+ [--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
- git branch [--track[=(direct|inherit)] | --no-track] [-f]
-@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
- Print the name of the current branch. In detached `HEAD` state,
- nothing is printed.
+@@ Documentation/git-branch.adoc: merged into the named commit (i.e. the branches whose tip commits are
+ reachable from the named commit) will be listed. With `--no-merged` only
+ branches not merged into the named commit will be listed. If the _<commit>_
+ argument is missing it defaults to `HEAD` (i.e. the tip of the current
+-branch).
++branch). With `--forked`, only branches whose configured upstream matches
++the given branch or pattern will be listed.
+
+ The command's second form creates a new branch head named _<branch-name>_
+ which points to the current `HEAD`, or _<start-point>_ if given. As a
+@@ Documentation/git-branch.adoc: superproject's "origin/main", but tracks the submodule's "origin/main".
+ Only list branches whose tips are not reachable from
+ _<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
-+ List only branches whose configured upstream matches
++ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
-+ option can be repeated to widen the filter.
++ option can be repeated to widen the filter. Implies `--list`.
+
- `-v`::
- `-vv`::
- `--verbose`::
+ `--points-at <object>`::
+ Only list branches of _<object>_.
+
## builtin/branch.c ##
@@
- #include "help.h"
- #include "advice.h"
#include "commit-reach.h"
-+#include "wildmatch.h"
static const char * const builtin_branch_usage[] = {
- N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
@@ builtin/branch.c
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
-@@ builtin/branch.c: static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
- return strbuf_detach(&fmt, NULL);
+@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
+ free_worktrees(worktrees);
}
-+static void filter_array_by_forked(struct ref_array *array,
-+ const struct string_list *upstreams);
++static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
++{
++ struct ref_filter *filter = opt->value;
+
- 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;
-@@ builtin/branch.c: static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
++ 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")
- filter_refs(&array, filter, filter->kind);
+ static int edit_branch_description(const char *branch_name)
+@@ builtin/branch.c: 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 <branch> (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"),
+@@ builtin/branch.c: int cmd_branch(int argc,
+ list = 1;
-+ if (forked_upstreams->nr)
-+ filter_array_by_forked(&array, forked_upstreams);
-+
- if (filter->verbose)
- maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
+ 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;
-@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
- free_worktrees(worktrees);
+ noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
+
+ ## ref-filter.c ##
+@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const char *refname)
+ return match_pattern(filter->exclude.v, refname, filter->ignore_case);
}
-+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;
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ 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)
++/*
++ * 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(full_refname, "refs/heads/", &short_name))
++ if (!skip_prefix(refname, "refs/heads/", &short_name))
+ return 0;
+ branch = branch_get(short_name);
+ if (!branch)
@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
+ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream)
+ return 0;
-+ return upstream_matches(short_upstream_name(upstream),
-+ patterns, nr_patterns);
++
++ 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;
+}
+
-+static void filter_array_by_forked(struct ref_array *array,
-+ const struct string_list *upstreams)
++int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
-+ struct upstream_pattern *patterns = NULL;
-+ size_t nr_patterns = 0;
-+ int i, kept = 0;
-+
-+ parse_forked_args(upstreams, &patterns, &nr_patterns);
++ struct object_id oid;
++ char *full_ref = NULL;
+
-+ 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);
++ if (has_glob_specials(arg)) {
++ strvec_push(&filter->forked, arg);
++ return 0;
+ }
-+ array->nr = kept;
+
-+ upstream_pattern_list_clear(patterns, nr_patterns);
++ 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;
+}
+
- static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
-
- static int edit_branch_description(const char *branch_name)
-@@ builtin/branch.c: 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 */
-@@ builtin/branch.c: int cmd_branch(int argc,
- OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
- OPT_BOOL(0, "edit-description", &edit_description,
- N_("edit the description for the branch")),
-+ OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
-+ N_("list local branches whose upstream matches <branch> (repeatable)")),
- OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
- OPT_MERGED(&filter, N_("print only branches that are merged")),
- OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
-@@ builtin/branch.c: 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 +
-@@ builtin/branch.c: 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);
-@@ builtin/branch.c: int cmd_branch(int argc,
-
- out:
- string_list_clear(&sorting_options, 0);
-+ string_list_clear(&forked_upstreams, 0);
- return ret;
- }
-
- ## ref-filter.c ##
-@@ ref-filter.c: 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) {
-@@ ref-filter.c: 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);
+ /*
+ * 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.
+@@ ref-filter.c: 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;
++
/*
- * Increment the running count of refs that match the filter. If
-@@ ref-filter.c: 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;
-
-@@ ref-filter.c: 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);
-@@ ref-filter.c: 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)
+ * A merge filter is applied on refs pointing to commits. Hence
+ * obtain the commit using the 'oid' available and discard all
+@@ ref-filter.c: 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);
## ref-filter.h ##
-@@ ref-filter.h: 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 */
+@@ ref-filter.h: 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;
+@@ ref-filter.h: 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, \
+@@ ref-filter.h: 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 <branch> 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. */
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
2: 6c95e4e77c ! 2: a7672713f6 branch: let delete_branches warn instead of error on bulk refusal
@@ Metadata
## Commit message ##
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.
+ Add a warn-only mode to delete_branches() and check_branch_commit()
+ so a bulk caller can report branches that are not fully merged as a
+ short warning and carry on, rather than erroring with the longer
+ "use 'git branch -D'" advice that the plain "git branch -d" path
+ emits. Existing callers are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## builtin/branch.c ##
@@ builtin/branch.c: 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),
++ DELETE_BRANCH_WARN_ONLY = (1 << 2),
++};
++
static int check_branch_commit(const char *branchname, const char *refname,
const struct object_id *oid, struct commit *head_rev,
- int kinds, int force)
-+ int kinds, int force, int warn_only)
++ int kinds, unsigned int flags)
{
++ int force = flags & DELETE_BRANCH_FORCE;
struct commit *rev = lookup_commit_reference(the_repository, oid);
if (!force && !rev) {
-@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
+ error(_("couldn't look up commit object for '%s'"), refname);
return -1;
}
if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
- 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) {
++ if (flags & DELETE_BRANCH_WARN_ONLY) {
+ warning(_("the branch '%s' is not fully merged"),
+ branchname);
+ } else {
@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
}
return 0;
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
+ strbuf_release(&buf);
}
- static int delete_branches(int argc, const char **argv, int force, int kinds,
+-static int delete_branches(int argc, const char **argv, int force, int kinds,
- int quiet)
-+ int quiet, int warn_only)
++static int delete_branches(int argc, const char **argv, int kinds,
++ unsigned int flags)
{
struct commit *head_rev = NULL;
struct object_id oid;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ int i;
+ int ret = 0;
+ int remote_branch = 0;
++ int force = flags & DELETE_BRANCH_FORCE;
++ int 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;
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+
+ 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);
+@@ builtin/branch.c: 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);
+@@ builtin/branch.c: 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 (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
+ if (virtual_target)
+@@ builtin/branch.c: 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)) {
- ret = 1;
-+ force, warn_only)) {
-+ if (!warn_only)
++ flags)) {
++ if (!(flags & DELETE_BRANCH_WARN_ONLY))
+ 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:
@@ builtin/branch.c: 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);
++ 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();
3: 004a96f7a4 ! 3: 5ee7643d3a branch: prepare delete_branches for a bulk caller
@@ Metadata
## Commit message ##
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.
+ Teach delete_branches() two new modes for the upcoming
+ --prune-merged: one that asks only whether a branch is merged into
+ its upstream, without falling back to HEAD when there is no
+ upstream, and one that rehearses the deletions without removing any
+ ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
if (expect < 0)
exit(128);
if (expect == merged)
-@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
- }
+@@ builtin/branch.c: enum delete_branch_flags {
+ DELETE_BRANCH_FORCE = (1 << 0),
+ DELETE_BRANCH_QUIET = (1 << 1),
+ DELETE_BRANCH_WARN_ONLY = (1 << 2),
++ DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
++ DELETE_BRANCH_DRY_RUN = (1 << 4),
+ };
- 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;
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ static int check_branch_commit(const char *branchname, const char *refname,
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
+ int remote_branch = 0;
+ int force = flags & DELETE_BRANCH_FORCE;
+ int quiet = flags & DELETE_BRANCH_QUIET;
++ int dry_run = flags & DELETE_BRANCH_DRY_RUN;
++ int 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;
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
-@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
free(target);
}
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
char *refname = name + branch_name_pos;
if (!quiet)
printf(remote_branch
-@@ builtin/branch.c: 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();
4: cccfdb831c ! 4: 5f913c445c branch: add --prune-merged <branch>
@@ Commit message
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
- restricted to those whose tip is reachable from their configured
+ keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
- Reachability is read from local refs; nothing is fetched. Users
- who want fresh upstream refs run "git fetch" first.
+ Reachability is read from local refs; nothing is fetched. Run
+ "git fetch" first if you want fresh upstream refs.
- Three classes of branches are spared:
+ Three kinds 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 upstream no longer resolves locally, since a
+ missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
- (<branch>@{push} == <branch>@{upstream}). Such a branch
- cannot be distinguished from a freshly pulled trunk that
- just looks "fully merged", e.g. local "main" tracking and
- pushing to "origin/main" right after a pull. Only branches
- that push somewhere other than their upstream (typically
- topics in a fork-based workflow) are treated as candidates.
+ (<branch>@{push} is the same as <branch>@{upstream}), such as
+ a local "main" that tracks and pushes to "origin/main". Right
+ after a pull it just looks "fully merged", so it is left
+ alone. Only branches that push somewhere other than their
+ upstream, typically topics in a fork workflow, are candidates.
- 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.
+ Branches that are not yet merged into their upstream are reported
+ as a short warning and skipped, so one unmerged topic does not
+ abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-+git branch (--prune-merged <branch>)...
++git branch --prune-merged <branch>...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: 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.
+ Print the name of the current branch. In detached `HEAD` state,
+ nothing is printed.
-+`--prune-merged <branch>`::
++`--prune-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
-+ same _<branch>_, but only those whose tip is reachable from
-+ their configured upstream. In other words, the work on the
-+ branch has already landed on the upstream it tracks, so the
-+ local copy is no longer needed. May be given more than once to
-+ union the matches; positional arguments are not accepted.
++ given _<branch>_ arguments, but only those whose tip is
++ reachable from their configured upstream. In other words, the
++ work on the branch has already landed on the upstream it
++ tracks, so the local copy is no longer needed. Several
++ _<branch>_ patterns may be given, e.g. `git branch
++ --prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] (--prune-merged <branch>)..."),
++ N_("git branch [<options>] --prune-merged <branch>..."),
NULL
};
-@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
+@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
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);
-@@ builtin/branch.c: 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);
-@@ builtin/branch.c: 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,
++static int prune_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
-+ struct string_list candidates = STRING_LIST_INIT_DUP;
++ struct ref_filter filter = REF_FILTER_INIT;
++ struct ref_array candidates;
+ struct strvec deletable = STRVEC_INIT;
-+ struct string_list_item *item;
-+ int ret = 0;
++ int i, ret = 0;
+
-+ if (!upstreams->nr)
++ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
-+ collect_forked_set(upstreams, &candidates);
++ 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]);
+
-+ for_each_string_list_item(item, &candidates) {
-+ const char *short_name = item->string;
-+ struct branch *branch = branch_get(short_name);
++ filter.kind = FILTER_REFS_BRANCHES;
++ memset(&candidates, 0, sizeof(candidates));
++ 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;
-+ 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)
++ if (!skip_prefix(full_name, "refs/heads/", &short_name))
++ continue;
++ if (branch_checked_out(full_name))
+ continue;
+
++ branch = branch_get(short_name);
+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
+
+ 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 */);
++ DELETE_BRANCH_WARN_ONLY |
++ DELETE_BRANCH_NO_HEAD_FALLBACK |
++ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_clear(&deletable);
-+ string_list_clear(&candidates, 0);
++ ref_array_clear(&candidates);
++ ref_filter_clear(&filter);
+ return ret;
+}
+
@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
static int edit_branch_description(const char *branch_name)
@@ builtin/branch.c: 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;
-+ struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
++ int prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
+ OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
+ OPT_BOOL(0, "edit-description", &edit_description,
N_("edit the description for the branch")),
- OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
- N_("list local branches whose upstream matches <branch> (repeatable)")),
-+ OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
-+ N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
++ OPT_BOOL(0, "prune-merged", &prune_merged,
++ N_("delete local branches whose upstream matches <branch> and is 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")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
++ !show_current && !unset_upstream && !prune_merged &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: 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;
++ !!unset_upstream + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
- ret = delete_branches(argc, argv, delete > 1, filter.kind,
- quiet, 0, 0, 0);
+ (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
-+ } else if (prune_merged_upstreams.nr) {
-+ if (argc)
-+ die(_("--prune-merged does not take positional arguments; "
-+ "repeat --prune-merged for each <branch>"));
-+ ret = prune_merged_branches(&prune_merged_upstreams, quiet);
++ } else if (prune_merged) {
++ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
-@@ builtin/branch.c: 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;
- }
## t/t3200-branch.sh ##
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 &&
++ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
-+test_expect_success '--prune-merged requires a value' '
++test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
-+ test_grep "requires a value" err
++ test_grep "requires at least one <branch>" 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_expect_success '--prune-merged takes positional <branch> arguments' '
++ test_when_finished "rm -rf pm-positional" &&
++ git clone pm-upstream pm-positional &&
++ git -C pm-positional remote add fork ../pm-fork &&
++ test_config -C pm-positional remote.pushDefault fork &&
++ test_config -C pm-positional push.default current &&
++ git -C pm-positional branch one one-commit &&
++ git -C pm-positional branch --set-upstream-to=origin/next one &&
++ git -C pm-positional branch two base &&
++ git -C pm-positional branch --set-upstream-to=origin/main two &&
++ git -C pm-positional checkout --detach &&
++
++ git -C pm-positional branch --prune-merged origin/next origin/main &&
++
++ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
++ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
5: 5f793f8d0d ! 5: 8e9a735ffe branch: add branch.<name>.pruneMerged opt-out
@@ Commit message
branch: add branch.<name>.pruneMerged opt-out
Setting branch.<name>.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.
+ "git branch --prune-merged", which is useful for a topic you want
+ to keep developing after an early round of it has been merged
+ upstream. Unless --quiet is given, each skip is reported so the
+ user knows why their topic was kept.
- 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.
+ Explicit deletion with "git branch -d" still uses the normal merge
+ check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: the upstream refs refreshed.
warnings and skipped; pass them to `git branch -D` explicitly if
## builtin/branch.c ##
-@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
- struct branch *branch = branch_get(short_name);
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ const char *short_name;
+ struct branch *branch;
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);
-@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ continue;
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *ups
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
- test_grep "does not take positional arguments" err
+@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
6: 1a0d5eab15 ! 6: 511de4788e branch: add --dry-run for --prune-merged
@@ Commit message
branch: add --dry-run for --prune-merged
With --dry-run, --prune-merged prints the local branches it would
- delete, one "Would delete branch <name>" line per candidate, and
- exits without touching any ref.
+ delete, one "Would delete branch <name>" line each, and exits
+ without touching any ref. The same filtering applies, so the output
+ is exactly the set that the real run would delete.
- 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.
+ --dry-run is only meaningful together with --prune-merged and is
+ rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch (--prune-merged <branch>)...
-+git branch [--dry-run] (--prune-merged <branch>)...
+-git branch --prune-merged <branch>...
++git branch [--dry-run] --prune-merged <branch>...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety che
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,
+@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
- static int prune_merged_branches(const struct string_list *upstreams,
+ static int prune_merged_branches(int argc, const char **argv,
- 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;
-@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
- quiet,
- 1, /* warn_only */
- 1, /* no_head_fallback */
-- 0 /* dry_run */);
-+ dry_run);
+ struct ref_filter filter = REF_FILTER_INIT;
+@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+- (quiet ? DELETE_BRANCH_QUIET : 0));
++ (quiet ? DELETE_BRANCH_QUIET : 0) |
++ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
- string_list_clear(&candidates, 0);
+ ref_array_clear(&candidates);
@@ builtin/branch.c: 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;
+ int prune_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
- N_("list local branches whose upstream matches <branch> (repeatable)")),
- OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
- N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
+ N_("edit the description for the branch")),
+ OPT_BOOL(0, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches <branch> and is merged")),
+ 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),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !prune_merged_upstreams.nr)
++ if (dry_run && !prune_merged)
+ 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"));
@@ builtin/branch.c: int cmd_branch(int argc,
- if (argc)
- die(_("--prune-merged does not take positional arguments; "
- "repeat --prune-merged for each <branch>"));
-- ret = prune_merged_branches(&prune_merged_upstreams, quiet);
-+ ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+ goto out;
+ } else if (prune_merged) {
+- ret = prune_merged_branches(argc, argv, quiet);
++ ret = prune_merged_branches(argc, argv, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v13 1/6] branch: add --forked filter for --list mode
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 10 +++-
builtin/branch.c | 18 ++++++-
ref-filter.c | 70 ++++++++++++++++++++++++++
ref-filter.h | 10 ++++
t/t3200-branch.sh | 92 +++++++++++++++++++++++++++++++++++
5 files changed, 197 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..62ebab6051 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -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 _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,12 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -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 <branch> (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 <branch> 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..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ test_when_finished "git -C forked checkout detached" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-08 23:56 ` Junio C Hamano
2026-06-05 18:35 ` [PATCH v13 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a warn-only mode to delete_branches() and check_branch_commit()
so a bulk caller can report branches that are not fully merged as a
short warning and carry on, rather than erroring with the longer
"use 'git branch -D'" advice that the plain "git branch -d" path
emits. Existing callers are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 50 ++++++++++++++++++++++++++++++++----------------
1 file changed, 34 insertions(+), 16 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..19d6147e71 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,20 +189,33 @@ 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),
+ DELETE_BRANCH_WARN_ONLY = (1 << 2),
+};
+
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)
{
+ int 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);
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 (flags & DELETE_BRANCH_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;
@@ -217,8 +230,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 +240,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
int i;
int ret = 0;
int remote_branch = 0;
+ int force = flags & DELETE_BRANCH_FORCE;
+ int 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;
@@ -257,7 +272,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
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 +294,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 +306,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 +321,17 @@ 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)) {
- ret = 1;
+ flags)) {
+ if (!(flags & DELETE_BRANCH_WARN_ONLY))
+ 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 +888,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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-05 18:35 ` [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-08 23:56 ` Junio C Hamano
2026-06-09 7:52 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Junio C Hamano @ 2026-06-08 23:56 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget
Cc: git, Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
"Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Add a warn-only mode to delete_branches() and check_branch_commit()
> so a bulk caller can report branches that are not fully merged as a
> short warning and carry on, rather than erroring with the longer
> "use 'git branch -D'" advice that the plain "git branch -d" path
> emits. Existing callers are unaffected.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> builtin/branch.c | 50 ++++++++++++++++++++++++++++++++----------------
> 1 file changed, 34 insertions(+), 16 deletions(-)
This breaks t5404, t5514, and t5505, which contradicts with
"Existing callers are unaffected".
What's going on? It is troubling that the breakage happens without
even getting merged with other topics in-flight, which means that
the environment you are developing in and testing on and the
environment that I apply patches on, integrate and test (something
based on Debian testing) are somehow behaving differently.
"cd t && sh t5404-*.sh -i -v" ends like so:
expecting success of 5404.7 'already deleted tracking branches ignored':
git branch -d -r origin/b3 &&
git push origin :b3 >output 2>&1 &&
! grep "^error: " output
error: the branch 'origin/b3' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D origin/b3'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
not ok 7 - already deleted tracking branches ignored
#
# git branch -d -r origin/b3 &&
# git push origin :b3 >output 2>&1 &&
# ! grep "^error: " output
#
1..7
but it may be possible that earlier steps are behaving differently
with the patches applied. I didn't dig further but I think the CI
in the recent past have been affected by the same breakage.
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-08 23:56 ` Junio C Hamano
@ 2026-06-09 7:52 ` Harald Nordgren
2026-06-09 12:38 ` Junio C Hamano
0 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-09 7:52 UTC (permalink / raw)
To: Junio C Hamano
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt, Phillip Wood
> This breaks t5404, t5514, and t5505, which contradicts with
> "Existing callers are unaffected".
>
> What's going on? It is troubling that the breakage happens without
> even getting merged with other topics in-flight, which means that
> the environment you are developing in and testing on and the
> environment that I apply patches on, integrate and test (something
> based on Debian testing) are somehow behaving differently.
>
> "cd t && sh t5404-*.sh -i -v" ends like so:
>
> expecting success of 5404.7 'already deleted tracking branches ignored':
> git branch -d -r origin/b3 &&
> git push origin :b3 >output 2>&1 &&
> ! grep "^error: " output
>
> error: the branch 'origin/b3' is not fully merged
> hint: If you are sure you want to delete it, run 'git branch -D origin/b3'
> hint: Disable this message with "git config set advice.forceDeleteBranch false"
> not ok 7 - already deleted tracking branches ignored
> #
> # git branch -d -r origin/b3 &&
> # git push origin :b3 >output 2>&1 &&
> # ! grep "^error: " output
> #
> 1..7
>
> but it may be possible that earlier steps are behaving differently
> with the patches applied. I didn't dig further but I think the CI
> in the recent past have been affected by the same breakage.
Thanks for directing my attention to this.
The GitHub CI has been broken for some time, maybe I should have told
you about this earlier, but it coincided with a period where other
open source projects I worked on also had mass CI failures, so I
chalked it up to upstream issues (GitHub, Linux, etc). But it seems to
have not gone away.
All of my GitHub pull requests have broken tests (see e.g. which a
quite minimal change: https://github.com/git/git/pull/2313). This
makes it harder to detect actual issues. But of course it's not an
excuse.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-09 7:52 ` Harald Nordgren
@ 2026-06-09 12:38 ` Junio C Hamano
2026-06-09 13:20 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Junio C Hamano @ 2026-06-09 12:38 UTC (permalink / raw)
To: Harald Nordgren
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt, Phillip Wood
Harald Nordgren <haraldnordgren@gmail.com> writes:
> The GitHub CI has been broken for some time, maybe I should have told
> you about this earlier, but it coincided with a period where other
> open source projects I worked on also had mass CI failures, so I
> chalked it up to upstream issues (GitHub, Linux, etc). But it seems to
> have not gone away.
>
> All of my GitHub pull requests have broken tests (see e.g. which a
> quite minimal change: https://github.com/git/git/pull/2313). This
> makes it harder to detect actual issues. But of course it's not an
> excuse.
FWIW, the breakage was observed in my local testing, and that is why
I found it so dusturbing. Apparently you didn't see such breakages
that can be detected so easily during your local testing (otherwise
you wouldn't have pushed it out to update your GitHub pull request),
which may mean something in the test are platform dependent?
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-09 12:38 ` Junio C Hamano
@ 2026-06-09 13:20 ` Harald Nordgren
0 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-09 13:20 UTC (permalink / raw)
To: Junio C Hamano
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt, Phillip Wood
On Tue, Jun 9, 2026 at 2:38 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> Harald Nordgren <haraldnordgren@gmail.com> writes:
>
> > The GitHub CI has been broken for some time, maybe I should have told
> > you about this earlier, but it coincided with a period where other
> > open source projects I worked on also had mass CI failures, so I
> > chalked it up to upstream issues (GitHub, Linux, etc). But it seems to
> > have not gone away.
> >
> > All of my GitHub pull requests have broken tests (see e.g. which a
> > quite minimal change: https://github.com/git/git/pull/2313). This
> > makes it harder to detect actual issues. But of course it's not an
> > excuse.
>
> FWIW, the breakage was observed in my local testing, and that is why
> I found it so disturbing. Apparently you didn't see such breakages
> that can be detected so easily during your local testing (otherwise
> you wouldn't have pushed it out to update your GitHub pull request),
> which may mean something in the test are platform dependent?
No, it was broken on my local machine as well. I was sloppy when I
pushed out v13 and didn't run tests locally.
Usually I will push to my GitHub PR incrementally as I work on a new
version. I don’t necessarily keep the GitHub PR clean between
submitting versions. I will diff against my latest version tag to see
my own progress, and if I mess it up I can always hard reset to the
latest version tag to start over from there.
Normally, I would never push out a new version unless the GitHub CI
passes. But it’s been broken for a month.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v13 3/6] branch: prepare delete_branches for a bulk caller
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--prune-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 19d6147e71..9568bb8445 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,8 @@ enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
DELETE_BRANCH_WARN_ONLY = (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,
@@ -242,6 +247,8 @@ static int delete_branches(int argc, const char **argv, int kinds,
int remote_branch = 0;
int force = flags & DELETE_BRANCH_FORCE;
int quiet = flags & DELETE_BRANCH_QUIET;
+ int dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ int 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;
@@ -267,7 +274,7 @@ static int delete_branches(int argc, const char **argv, 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)) {
@@ -338,13 +345,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v13 4/6] branch: add --prune-merged <branch>
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-05 18:35 ` [PATCH v13 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from local refs; nothing is fetched. Run
"git fetch" first if you want fresh upstream refs.
Three kinds of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally, since a
missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is left
alone. Only branches that push somewhere other than their
upstream, typically topics in a fork workflow, are candidates.
Branches that are not yet merged into their upstream are reported
as a short warning and skipped, so one unmerged topic does not
abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 24 ++++
builtin/branch.c | 67 +++++++++++-
t/t3200-branch.sh | 201 ++++++++++++++++++++++++++++++++++
3 files changed, 290 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 62ebab6051..fdaccc9662 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --prune-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--prune-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 9568bb8445..7a26447b2a 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --prune-merged <branch>..."),
NULL
};
@@ -713,6 +714,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+static int prune_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates;
+ struct strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ memset(&candidates, 0, sizeof(candidates));
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream, *push;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ continue;
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
+ 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,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_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)
@@ -754,6 +810,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 prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -807,6 +864,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, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches <branch> and is 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")),
@@ -854,7 +913,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 &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -864,7 +924,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -906,6 +966,9 @@ int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..27ea1319bb 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--prune-merged takes positional <branch> arguments' '
+ test_when_finished "rm -rf pm-positional" &&
+ git clone pm-upstream pm-positional &&
+ git -C pm-positional remote add fork ../pm-fork &&
+ test_config -C pm-positional remote.pushDefault fork &&
+ test_config -C pm-positional push.default current &&
+ git -C pm-positional branch one one-commit &&
+ git -C pm-positional branch --set-upstream-to=origin/next one &&
+ git -C pm-positional branch two base &&
+ git -C pm-positional branch --set-upstream-to=origin/main two &&
+ git -C pm-positional checkout --detach &&
+
+ git -C pm-positional branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v13 5/6] branch: add branch.<name>.pruneMerged opt-out
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-05 18:35 ` [PATCH v13 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-05 18:35 ` [PATCH v13 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
"git branch --prune-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ 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 fdaccc9662..5c43dc55a8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -217,9 +217,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 (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.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 7a26447b2a..be4218ded3 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -739,6 +739,8 @@ static int prune_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
+ struct strbuf key = STRBUF_INIT;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
continue;
@@ -753,6 +755,18 @@ static int prune_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!quiet)
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
+ continue;
+ }
+ strbuf_release(&key);
+
strvec_push(&deletable, short_name);
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 27ea1319bb..3f7b1fc3d6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '
test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v13 6/6] branch: add --dry-run for --prune-merged
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-05 18:35 ` [PATCH v13 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-05 18:35 ` Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --prune-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 13 ++++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5c43dc55a8..1f49a831fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --prune-merged <branch>...
+git branch [--dry-run] --prune-merged <branch>...
DESCRIPTION
-----------
@@ -226,6 +226,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 be4218ded3..98e56d4ff8 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -715,7 +715,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
static int prune_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct ref_filter filter = REF_FILTER_INIT;
@@ -775,7 +775,8 @@ static int prune_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES,
DELETE_BRANCH_WARN_ONLY |
DELETE_BRANCH_NO_HEAD_FALLBACK |
- (quiet ? DELETE_BRANCH_QUIET : 0));
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
ref_array_clear(&candidates);
@@ -825,6 +826,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 prune_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -880,6 +882,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "prune-merged", &prune_merged,
N_("delete local branches whose upstream matches <branch> and is merged")),
+ 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")),
@@ -942,6 +946,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged)
+ 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"));
@@ -981,7 +988,7 @@ int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
+ ret = prune_merged_branches(argc, argv, 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 3f7b1fc3d6..305c0141fc 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v14 0/6] branch: prune-merged
2026-06-05 18:35 ` [PATCH v13 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-05 18:35 ` [PATCH v13 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (6 more replies)
6 siblings, 7 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and
the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other
branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Harald Nordgren (6):
branch: add --forked filter for --list mode
branch: let delete_branches warn instead of error on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --prune-merged <branch>
branch: add branch.<name>.pruneMerged opt-out
branch: add --dry-run for --prune-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 41 +++-
builtin/branch.c | 186 +++++++++++++---
ref-filter.c | 70 ++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 367 +++++++++++++++++++++++++++++++
6 files changed, 653 insertions(+), 28 deletions(-)
base-commit: 600fe743028cbfb640855f659e9851522214bc0b
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v14
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v14
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v13:
1: ccd07cff25 = 1: 7383872f4b branch: add --forked filter for --list mode
2: a7672713f6 ! 2: 7ef9502e01 branch: let delete_branches warn instead of error on bulk refusal
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
int i;
int ret = 0;
int remote_branch = 0;
-+ int force = flags & DELETE_BRANCH_FORCE;
-+ int quiet = flags & DELETE_BRANCH_QUIET;
++ int force, quiet;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: 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";
+@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
+ }
+ branch_name_pos = strcspn(fmt, "%");
+
++ force = flags & DELETE_BRANCH_FORCE;
++ quiet = flags & DELETE_BRANCH_QUIET;
++
+ if (!force)
+ head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
char *target = NULL;
3: 5ee7643d3a ! 3: 259113e304 branch: prepare delete_branches for a bulk caller
@@ builtin/branch.c: enum delete_branch_flags {
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
+ int i;
+ int ret = 0;
int remote_branch = 0;
- int force = flags & DELETE_BRANCH_FORCE;
- int quiet = flags & DELETE_BRANCH_QUIET;
-+ int dry_run = flags & DELETE_BRANCH_DRY_RUN;
-+ int no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
+- int force, quiet;
++ int force, quiet, dry_run, no_head_fallback;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- }
- branch_name_pos = strcspn(fmt, "%");
+
+ force = flags & DELETE_BRANCH_FORCE;
+ quiet = flags & DELETE_BRANCH_QUIET;
++ dry_run = flags & DELETE_BRANCH_DRY_RUN;
++ no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
- if (!force)
+ if (!force && !no_head_fallback)
4: 5f913c445c = 4: 9924373da0 branch: add --prune-merged <branch>
5: 8e9a735ffe = 5: d691d5051b branch: add branch.<name>.pruneMerged opt-out
6: 511de4788e = 6: ede8c61729 branch: add --dry-run for --prune-merged
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v14 1/6] branch: add --forked filter for --list mode
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-15 9:46 ` Phillip Wood
2026-06-09 10:11 ` [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master") or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 10 +++-
builtin/branch.c | 18 ++++++-
ref-filter.c | 70 ++++++++++++++++++++++++++
ref-filter.h | 10 ++++
t/t3200-branch.sh | 92 +++++++++++++++++++++++++++++++++++
5 files changed, 197 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..62ebab6051 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -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 _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,12 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
+ option can be repeated to widen the filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -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 <branch> (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 <branch> 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..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
test_cmp expect actual
'
+test_expect_success '--forked: setup' '
+ test_create_repo forked-upstream &&
+ test_commit -C forked-upstream base &&
+ git -C forked-upstream branch one base &&
+ git -C forked-upstream branch two base &&
+
+ test_create_repo forked-other &&
+ test_commit -C forked-other other-base &&
+ git -C forked-other branch foreign other-base &&
+
+ git clone forked-upstream forked &&
+ git -C forked remote add other ../forked-other &&
+ git -C forked fetch other &&
+ git -C forked branch local-base &&
+ git -C forked branch --track local-one origin/one &&
+ git -C forked branch --track local-two origin/two &&
+ git -C forked branch --track local-foreign other/foreign &&
+ git -C forked branch detached &&
+ git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-trunk >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+ git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-trunk
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+ git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+ test_when_finished "git -C forked checkout detached" &&
+ git -C forked checkout local-one &&
+ test_commit -C forked local-only &&
+ git -C forked branch --forked "origin/*" --no-merged origin/one \
+ --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+ test_must_fail git -C forked branch --forked nope 2>err &&
+ test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 1/6] branch: add --forked filter for --list mode
2026-06-09 10:11 ` [PATCH v14 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-15 9:46 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-15 9:46 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Add a --forked option to "git branch" list mode that lists only
> branches whose configured upstream matches <branch>. The argument
> can be a ref (e.g. "origin/main", "master") or a shell glob
> (e.g. "origin/*"), and may be repeated to widen the filter.
>
> It is an ordinary list filter, so it combines with the others:
>
> git branch --merged origin/main --forked 'origin/*'
>
> lists branches forked from origin that are already merged into
> origin/main, and --no-merged inverts the question.
>
> This is the building block for --prune-merged, which deletes the
> listed branches once they have landed on their upstream.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 10 +++-
> builtin/branch.c | 18 ++++++-
> ref-filter.c | 70 ++++++++++++++++++++++++++
> ref-filter.h | 10 ++++
> t/t3200-branch.sh | 92 +++++++++++++++++++++++++++++++++++
> 5 files changed, 197 insertions(+), 3 deletions(-)
It's nice to see that moving the code into the ref-filter.c has reduced
the overall number of additions by ~50 lines. The documentation and
implementation look fine though I have a couple of thoughts:
- Previous iterations supported "origin" as a short hand for the branch
origin/HEAD points to. That was nice because it means we can use the
same syntax for "git checkout -b" and "git branch --forked". It
would probably be a good idea to support it.
- We could probably be a bit smarter about the way we handle patterns
by copying what dwim_ref() does to support things like
remotes/origin/* but I don't think we need to do that now.
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index e7829c2c4b..4e7deddc04 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
> test_cmp expect actual
> '
>
> +test_expect_success '--forked: setup' '
> + test_create_repo forked-upstream &&
> + test_commit -C forked-upstream base &&
> + git -C forked-upstream branch one base &&
> + git -C forked-upstream branch two base &&
> +
> + test_create_repo forked-other &&
> + test_commit -C forked-other other-base &&
> + git -C forked-other branch foreign other-base &&
> +
> + git clone forked-upstream forked &&
> + git -C forked remote add other ../forked-other &&
We can use "add -f" to fetch here rather than doing it separately.
> + git -C forked fetch other &&
> + git -C forked branch local-base &&
> + git -C forked branch --track local-one origin/one &&
> + git -C forked branch --track local-two origin/two &&
> + git -C forked branch --track local-foreign other/foreign &&
> + git -C forked branch detached &&
Normally we use "detached" to mean no branch, lets read on and see how
this is used ...
> + git -C forked branch --track local-trunk local-base
> +'
> +
> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
> + git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
origin/one and origin/two point to the same commit, so this demonstrates
that we're checking the branch names, not the topology which is good.
All of the local branches point at their upstream which isn't very
realistic - I wonder if we should add some local commits?
The tests all look sensible, but there is no coverage for combining
--forked with branch names as in
git branch --forked <arg> <branch>
Thanks
Phillip
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <glob> filters by wildmatch' '
> + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <local-branch> matches branches with local upstream' '
> + git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
> + echo local-trunk >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked can be repeated to widen the filter' '
> + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-trunk
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
> + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked composes with --no-merged' '
> + test_when_finished "git -C forked checkout detached" &&
> + git -C forked checkout local-one &&
> + test_commit -C forked local-only &&
> + git -C forked branch --forked "origin/*" --no-merged origin/one \
> + --format="%(refname:short)" >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked rejects unknown branch/pattern' '
> + test_must_fail git -C forked branch --forked nope 2>err &&
> + test_grep "not a valid branch or pattern" err
> +'
> +
> +test_expect_success '--forked requires a value' '
> + test_must_fail git -C forked branch --forked 2>err &&
> + test_grep "requires a value" err
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-09 13:21 ` Phillip Wood
2026-06-09 10:11 ` [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a warn-only mode to delete_branches() and check_branch_commit()
so a bulk caller can report branches that are not fully merged as a
short warning and carry on, rather than erroring with the longer
"use 'git branch -D'" advice that the plain "git branch -d" path
emits. Existing callers are unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 54 +++++++++++++++++++++++++++++++++---------------
1 file changed, 37 insertions(+), 17 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..4fb012c7a4 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,20 +189,33 @@ 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),
+ DELETE_BRANCH_WARN_ONLY = (1 << 2),
+};
+
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)
{
+ int 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);
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 (flags & DELETE_BRANCH_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;
@@ -217,8 +230,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 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
int i;
int ret = 0;
int remote_branch = 0;
+ int force, quiet;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -241,7 +255,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 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
}
branch_name_pos = strcspn(fmt, "%");
+ force = flags & DELETE_BRANCH_FORCE;
+ quiet = flags & DELETE_BRANCH_QUIET;
+
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 +296,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 +308,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 +323,17 @@ 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)) {
- ret = 1;
+ flags)) {
+ if (!(flags & DELETE_BRANCH_WARN_ONLY))
+ 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 +890,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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal
2026-06-09 10:11 ` [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-09 13:21 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-09 13:21 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Add a warn-only mode to delete_branches() and check_branch_commit()
> so a bulk caller can report branches that are not fully merged as a
> short warning and carry on, rather than erroring with the longer
> "use 'git branch -D'" advice that the plain "git branch -d" path
> emits. Existing callers are unaffected.
There is no mention here of the conversion to use a flags argument which
should be a separate preparatory commit
> @@ -189,20 +189,33 @@ 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),
> + DELETE_BRANCH_WARN_ONLY = (1 << 2),
> +};
> +
> 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)
> {
> + int force = flags & DELETE_BRANCH_FORCE;
This is missing "!!" to keep the value the same (alternatively we could
perhaps convert "force" to a bool, though I haven't looked too closely
at how it is used in the rest of the function). Apart from that this
good, unlike the conversion below it means the rest of the function sees
the same variables in the same state as it did before the conversion. It
would be a good idea to follow this pattern for the new flag.
bool warn = flags & DELETE_BRANCH_WARN_ONLY;
and then just use "warn" later. It is a common pattern in our code to
take a flags argument and split it out into various boolean variables at
the start of the function to avoid a lot of awkward bit masks in the
main body of the function.
> @@ -217,8 +230,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 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> int i;
> int ret = 0;
> int remote_branch = 0;
> + int force, quiet;
We should initialize these here using flags so that the rest of the code
sees the same variables and values as it did before the conversion.
> struct strbuf bname = STRBUF_INIT;
> enum interpret_branch_kind allowed_interpret;
> struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
> @@ -241,7 +255,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 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> }
> branch_name_pos = strcspn(fmt, "%");
>
> + force = flags & DELETE_BRANCH_FORCE;
> + quiet = flags & DELETE_BRANCH_QUIET;
> +
> 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;
This is sensible so we don't shadow the new function argument but this
added complexity is a good reason to split the flags change into its own
commit before adding the warning flag.
I'll take a look at the other patches later this week - there is no need
to send a new version before I've commented on the rest of the series.
Thanks
Phillip
>
> copy_branchname(&bname, argv[i], allowed_interpret);
> free(name);
> @@ -279,7 +296,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 +308,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 +323,17 @@ 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)) {
> - ret = 1;
> + flags)) {
> + if (!(flags & DELETE_BRANCH_WARN_ONLY))
> + 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 +890,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();
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 1/6] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-09 10:11 ` [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-15 9:47 ` Phillip Wood
2026-06-09 10:11 ` [PATCH v14 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--prune-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 28 +++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 4fb012c7a4..2cc5a8cde0 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,8 @@ enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
DELETE_BRANCH_WARN_ONLY = (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,
@@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
int i;
int ret = 0;
int remote_branch = 0;
- int force, quiet;
+ int force, quiet, dry_run, no_head_fallback;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -268,8 +273,10 @@ static int delete_branches(int argc, const char **argv, int kinds,
force = flags & DELETE_BRANCH_FORCE;
quiet = flags & DELETE_BRANCH_QUIET;
+ dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
- 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)) {
@@ -340,13 +347,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller
2026-06-09 10:11 ` [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-15 9:47 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-15 9:47 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
>
> @@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
> int i;
> int ret = 0;
> int remote_branch = 0;
> - int force, quiet;
> + int force, quiet, dry_run, no_head_fallback;
As with the previous patch it would be safer to initialize the new
variables where they are declared.
> for_each_string_list_item(item, &refs_to_delete) {
> char *describe_ref = item->util;
> char *name = item->string;
> - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> + if (dry_run) {
> + if (!quiet)
> + printf(remote_branch
> + ? _("Would delete remote-tracking branch %s (was %s).\n")
> + : _("Would delete branch %s (was %s).\n"),
I wondered what the "was %s" was about but it prints the symref target
or oid of the ref.
Thanks
Phillip
> + name + branch_name_pos, describe_ref);
> + } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> char *refname = name + branch_name_pos;
> if (!quiet)
> printf(remote_branch
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-09 10:11 ` [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-15 9:46 ` Phillip Wood
2026-06-16 9:59 ` Phillip Wood
2026-06-09 10:11 ` [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
6 siblings, 2 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream: the work has already landed on the upstream they track,
so the local copy is no longer needed.
Reachability is read from local refs; nothing is fetched. Run
"git fetch" first if you want fresh upstream refs.
Three kinds of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally, since a
missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is left
alone. Only branches that push somewhere other than their
upstream, typically topics in a fork workflow, are candidates.
Branches that are not yet merged into their upstream are reported
as a short warning and skipped, so one unmerged topic does not
abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 24 ++++
builtin/branch.c | 67 +++++++++++-
t/t3200-branch.sh | 201 ++++++++++++++++++++++++++++++++++
3 files changed, 290 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 62ebab6051..fdaccc9662 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --prune-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--prune-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --prune-merged origin/main 'feature*'`.
++
+Reachability is checked against whatever the upstream refs say
+locally; nothing is fetched. Run `git fetch` first if you want
+the upstream refs refreshed.
++
+A branch is left alone if any of the following holds:
+its upstream no longer resolves locally; it is checked out in any
+worktree; or its push destination (`<branch>@{push}`) equals its
+upstream (`<branch>@{upstream}`), so it cannot be distinguished
+from a freshly pulled trunk that just looks "fully merged".
++
+Branches refused by the "fully merged" safety check are listed as
+warnings and skipped; pass them to `git branch -D` explicitly if
+you want them gone.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 2cc5a8cde0..af37a0ceb7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --prune-merged <branch>..."),
NULL
};
@@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+static int prune_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates;
+ struct strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--prune-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ memset(&candidates, 0, sizeof(candidates));
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < candidates.nr; i++) {
+ const char *full_name = candidates.items[i]->refname;
+ const char *short_name;
+ struct branch *branch;
+ const char *upstream, *push;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
+ continue;
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
+ 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,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_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)
@@ -756,6 +812,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 prune_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -809,6 +866,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, "prune-merged", &prune_merged,
+ N_("delete local branches whose upstream matches <branch> and is 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")),
@@ -856,7 +915,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 &&
+ argc == 0)
list = 1;
if (filter.with_commit || filter.no_commit ||
@@ -866,7 +926,7 @@ int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
+ !!unset_upstream + !!prune_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ -908,6 +968,9 @@ int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (prune_merged) {
+ ret = prune_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..27ea1319bb 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
test_grep "requires a value" err
'
+test_expect_success '--prune-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
+ test_commit -C pm-upstream one-commit &&
+ test_commit -C pm-upstream two-commit &&
+ git -C pm-upstream branch one HEAD~ &&
+ git -C pm-upstream branch two HEAD &&
+ git -C pm-upstream branch wip main &&
+ git -C pm-upstream checkout main &&
+ test_create_repo pm-fork
+'
+
+test_expect_success '--prune-merged deletes branches integrated into upstream' '
+ test_when_finished "rm -rf pm-merged" &&
+ git clone pm-upstream pm-merged &&
+ git -C pm-merged remote add fork ../pm-fork &&
+ test_config -C pm-merged remote.pushDefault fork &&
+ test_config -C pm-merged push.default current &&
+ git -C pm-merged branch one one-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next one &&
+ git -C pm-merged branch two two-commit &&
+ git -C pm-merged branch --set-upstream-to=origin/next two &&
+
+ git -C pm-merged branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a literal upstream' '
+ test_when_finished "rm -rf pm-literal" &&
+ git clone pm-upstream pm-literal &&
+ git -C pm-literal remote add fork ../pm-fork &&
+ test_config -C pm-literal remote.pushDefault fork &&
+ test_config -C pm-literal push.default current &&
+ git -C pm-literal branch one one-commit &&
+ git -C pm-literal branch --set-upstream-to=origin/next one &&
+
+ git -C pm-literal branch --prune-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged accepts a local upstream' '
+ test_when_finished "rm -rf pm-local" &&
+ git clone pm-upstream pm-local &&
+ git -C pm-local remote add fork ../pm-fork &&
+ test_config -C pm-local remote.pushDefault fork &&
+ test_config -C pm-local push.default current &&
+ git -C pm-local checkout -b trunk &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=trunk one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --prune-merged trunk &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
+ test_when_finished "rm -rf pm-unmerged" &&
+ git clone pm-upstream pm-unmerged &&
+ git -C pm-unmerged remote add fork ../pm-fork &&
+ test_config -C pm-unmerged remote.pushDefault fork &&
+ test_config -C pm-unmerged push.default current &&
+ git -C pm-unmerged checkout -b wip origin/wip &&
+ git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
+ test_commit -C pm-unmerged local-only &&
+ git -C pm-unmerged checkout - &&
+
+ git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
+ test_grep "not fully merged" err &&
+ test_grep ! "If you are sure you want to delete it" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
+ test_when_finished "rm -rf pm-nohead" &&
+ git clone pm-upstream pm-nohead &&
+ git -C pm-nohead remote add fork ../pm-fork &&
+ test_config -C pm-nohead remote.pushDefault fork &&
+ test_config -C pm-nohead push.default current &&
+ git -C pm-nohead branch topic one-commit &&
+ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
+
+ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
+
+ test_grep ! "not yet merged to HEAD" err &&
+ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged skips branches whose upstream is gone' '
+ test_when_finished "rm -rf pm-upstream-gone" &&
+ git clone pm-upstream pm-upstream-gone &&
+ git -C pm-upstream-gone remote add fork ../pm-fork &&
+ test_config -C pm-upstream-gone remote.pushDefault fork &&
+ test_config -C pm-upstream-gone push.default current &&
+ git -C pm-upstream-gone branch one one-commit &&
+ git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
+
+ git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
+ git -C pm-upstream-gone branch --prune-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged never deletes the checked-out branch' '
+ test_when_finished "rm -rf pm-head" &&
+ git clone pm-upstream pm-head &&
+ git -C pm-head remote add fork ../pm-fork &&
+ test_config -C pm-head remote.pushDefault fork &&
+ test_config -C pm-head push.default current &&
+ git -C pm-head checkout -b one one-commit &&
+ git -C pm-head branch --set-upstream-to=origin/next one &&
+
+ git -C pm-head branch --prune-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--prune-merged spares branches that push back to their upstream' '
+ test_when_finished "rm -rf pm-push-eq" &&
+ git clone pm-upstream pm-push-eq &&
+ git -C pm-push-eq checkout --detach &&
+
+ git -C pm-push-eq branch --prune-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
+ test_when_finished "rm -rf pm-push-branch" &&
+ git clone pm-upstream pm-push-branch &&
+ git -C pm-push-branch remote add fork ../pm-fork &&
+ test_config -C pm-push-branch remote.pushDefault fork &&
+ test_config -C pm-push-branch push.default current &&
+ test_config -C pm-push-branch branch.main.pushRemote origin &&
+ git -C pm-push-branch checkout --detach &&
+
+ git -C pm-push-branch branch --prune-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
+ test_when_finished "rm -rf pm-push-diff" &&
+ git clone pm-upstream pm-push-diff &&
+ git -C pm-push-diff remote add fork ../pm-fork &&
+ test_config -C pm-push-diff remote.pushDefault fork &&
+ test_config -C pm-push-diff push.default current &&
+ git -C pm-push-diff branch topic one-commit &&
+ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
+ git -C pm-push-diff checkout --detach &&
+
+ git -C pm-push-diff branch --prune-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--prune-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --prune-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--prune-merged takes positional <branch> arguments' '
+ test_when_finished "rm -rf pm-positional" &&
+ git clone pm-upstream pm-positional &&
+ git -C pm-positional remote add fork ../pm-fork &&
+ test_config -C pm-positional remote.pushDefault fork &&
+ test_config -C pm-positional push.default current &&
+ git -C pm-positional branch one one-commit &&
+ git -C pm-positional branch --set-upstream-to=origin/next one &&
+ git -C pm-positional branch two base &&
+ git -C pm-positional branch --set-upstream-to=origin/main two &&
+ git -C pm-positional checkout --detach &&
+
+ git -C pm-positional branch --prune-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-09 10:11 ` [PATCH v14 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-15 9:46 ` Phillip Wood
2026-06-16 9:59 ` Phillip Wood
1 sibling, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-15 9:46 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> git branch --prune-merged <branch>...
Please see my comments on the previous version about the naming of this
option. I really think we need to start a discussion to find a better
name for this option as the other options to delete a branch are named
"delete" rather than "prune" and this does not remove the branches
listed by "--merge"
> deletes the local branches that "--forked <branch>" would list,
> keeping only those whose tip is reachable from their configured
> upstream: the work has already landed on the upstream they track,
> so the local copy is no longer needed.
>
> Reachability is read from local refs; nothing is fetched. Run
> "git fetch" first if you want fresh upstream refs.
I don't think this sentence adds anything - git never fetches unless
the user explicitly asks it to.
>
> Three kinds of branches are spared:
>
> * any branch checked out in any worktree;
> * any branch whose upstream no longer resolves locally, since a
> missing upstream is not by itself a sign of integration;
> * any branch whose push destination equals its upstream
> (<branch>@{push} is the same as <branch>@{upstream}), such as
> a local "main" that tracks and pushes to "origin/main". Right
> after a pull it just looks "fully merged", so it is left
> alone. Only branches that push somewhere other than their
> upstream, typically topics in a fork workflow, are candidates.
>
> Branches that are not yet merged into their upstream are reported
> as a short warning and skipped, so one unmerged topic does not
> abort the whole sweep.
I'm not sure about this warning - the user has asked us to delete the
branches whose upstreams match those passed on the commandline and that
have been merged so do they really want to hear about the ones that have
not been merged? It might be useful to have a way to list those that
have not been merged in the future.
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 24 ++++
> builtin/branch.c | 67 +++++++++++-
> t/t3200-branch.sh | 201 ++++++++++++++++++++++++++++++++++
> 3 files changed, 290 insertions(+), 2 deletions(-)
>
> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 62ebab6051..fdaccc9662 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
> git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> +git branch --prune-merged <branch>...
>
> DESCRIPTION
> -----------
> @@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
>
> +`--prune-merged <branch>...`::
> + Delete the local branches that `--forked` would list for the
> + given _<branch>_ arguments, but only those whose tip is
> + reachable from their configured upstream. In other words, the
> + work on the branch has already landed on the upstream it
> + tracks, so the local copy is no longer needed. Several
> + _<branch>_ patterns may be given, e.g. `git branch
> + --prune-merged origin/main 'feature*'`.
> ++
> +Reachability is checked against whatever the upstream refs say
> +locally; nothing is fetched. Run `git fetch` first if you want
> +the upstream refs refreshed.
Maybe
Reachability is checked against the remote-tracking branch. Run `git
fetch` first if you want update the remote-tracking branch.
> ++
> +A branch is left alone if any of the following holds:
s/left alone/not deleted/
> +its upstream no longer resolves locally; it is checked out in any
s/upstream no longer resolves locally/upstream remote-tracking branch no
longer exists/
> +worktree; or its push destination (`<branch>@{push}`) equals its
> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +from a freshly pulled trunk that just looks "fully merged".
What's a "freshly pulled trunk"? "trunk" does not appear in gitglossary(7)
> ++
> +Branches refused by the "fully merged" safety check are listed as
> +warnings and skipped; pass them to `git branch -D` explicitly if
> +you want them gone.
s/them gone/to delete them/
> +
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 2cc5a8cde0..af37a0ceb7 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
> N_("git branch [<options>] [-r | -a] [--points-at]"),
> N_("git branch [<options>] [-r | -a] [--format]"),
> + N_("git branch [<options>] --prune-merged <branch>..."),
> NULL
> };
>
> @@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
> return 0;
> }
>
> +static int prune_merged_branches(int argc, const char **argv,
> + int quiet)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct ref_filter filter = REF_FILTER_INIT;
> + struct ref_array candidates;
> + struct strvec deletable = STRVEC_INIT;
> + int i, ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + for (i = 0; i < argc; i++)
> + if (ref_filter_forked_add(&filter, argv[i]) < 0)
> + die(_("'%s' is not a valid branch or pattern"), argv[i]);
> +
> + filter.kind = FILTER_REFS_BRANCHES;
> + memset(&candidates, 0, sizeof(candidates));
It would be nicer to add "= { 0 }" to the declaration of candidates above.
> + filter_refs(&candidates, &filter, filter.kind);
> +
> + for (i = 0; i < candidates.nr; i++) {
> + const char *full_name = candidates.items[i]->refname;
> + const char *short_name;
> + struct branch *branch;
> + const char *upstream, *push;
> +
> + if (!skip_prefix(full_name, "refs/heads/", &short_name))
> + continue;
If we've set filter.kind = FILTER_REFS_BRANCHS how can this condition fail?
> + if (branch_checked_out(full_name))
> + continue;
> +
> + branch = branch_get(short_name);
> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
How can branch be NULL? Don't we require branch_get() to succeed in
order to filter it?
> + if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> + push = branch ? branch_get_push(branch, NULL) : NULL;
> + if (!push || !strcmp(push, upstream))
> + continue;
By the time we've reached this point we know that
branch@{upstream}exists and does not match branch@{push} - good
> + strvec_push(&deletable, short_name);
> + }
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + FILTER_REFS_BRANCHES,
> + DELETE_BRANCH_WARN_ONLY |
> + DELETE_BRANCH_NO_HEAD_FALLBACK |
> + (quiet ? DELETE_BRANCH_QUIET : 0));
Here we delete the branches - good.
> + OPT_BOOL(0, "prune-merged", &prune_merged,
> + N_("delete local branches whose upstream matches <branch> and is merged")),
s/is/are/
Sorry I didn't get round to reviewing these last week, I'll try and take
a look at the tests and the other patches tomorrow
Thanks
Phillip
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..27ea1319bb 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
> test_grep "requires a value" err
> '
>
> +test_expect_success '--prune-merged: setup' '
> + test_create_repo pm-upstream &&
> + test_commit -C pm-upstream base &&
> + git -C pm-upstream checkout -b next &&
> + test_commit -C pm-upstream one-commit &&
> + test_commit -C pm-upstream two-commit &&
> + git -C pm-upstream branch one HEAD~ &&
> + git -C pm-upstream branch two HEAD &&
> + git -C pm-upstream branch wip main &&
> + git -C pm-upstream checkout main &&
> + test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> + test_when_finished "rm -rf pm-merged" &&
> + git clone pm-upstream pm-merged &&
> + git -C pm-merged remote add fork ../pm-fork &&
> + test_config -C pm-merged remote.pushDefault fork &&
> + test_config -C pm-merged push.default current &&
> + git -C pm-merged branch one one-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next one &&
> + git -C pm-merged branch two two-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next two &&
> +
> + git -C pm-merged branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a literal upstream' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
> + git -C pm-literal remote add fork ../pm-fork &&
> + test_config -C pm-literal remote.pushDefault fork &&
> + test_config -C pm-literal push.default current &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> + git -C pm-union remote add fork ../pm-fork &&
> + test_config -C pm-union remote.pushDefault fork &&
> + test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> + git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next origin/main &&
> +
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> + git clone pm-upstream pm-local &&
> + git -C pm-local remote add fork ../pm-fork &&
> + test_config -C pm-local remote.pushDefault fork &&
> + test_config -C pm-local push.default current &&
> + git -C pm-local checkout -b trunk &&
> + git -C pm-local branch one one-commit &&
> + git -C pm-local branch --set-upstream-to=trunk one &&
> + git -C pm-local merge --ff-only one-commit &&
> +
> + git -C pm-local branch --prune-merged trunk &&
> +
> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + git -C pm-unmerged remote add fork ../pm-fork &&
> + test_config -C pm-unmerged remote.pushDefault fork &&
> + test_config -C pm-unmerged push.default current &&
> + git -C pm-unmerged checkout -b wip origin/wip &&
> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-unmerged local-only &&
> + git -C pm-unmerged checkout - &&
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> + test_when_finished "rm -rf pm-nohead" &&
> + git clone pm-upstream pm-nohead &&
> + git -C pm-nohead remote add fork ../pm-fork &&
> + test_config -C pm-nohead remote.pushDefault fork &&
> + test_config -C pm-nohead push.default current &&
> + git -C pm-nohead branch topic one-commit &&
> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> + test_grep ! "not yet merged to HEAD" err &&
> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> + test_when_finished "rm -rf pm-upstream-gone" &&
> + git clone pm-upstream pm-upstream-gone &&
> + git -C pm-upstream-gone remote add fork ../pm-fork &&
> + test_config -C pm-upstream-gone remote.pushDefault fork &&
> + test_config -C pm-upstream-gone push.default current &&
> + git -C pm-upstream-gone branch one one-commit &&
> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> + test_when_finished "rm -rf pm-head" &&
> + git clone pm-upstream pm-head &&
> + git -C pm-head remote add fork ../pm-fork &&
> + test_config -C pm-head remote.pushDefault fork &&
> + test_config -C pm-head push.default current &&
> + git -C pm-head checkout -b one one-commit &&
> + git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-head branch --prune-merged "origin/*" &&
> +
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> + test_when_finished "rm -rf pm-push-eq" &&
> + git clone pm-upstream pm-push-eq &&
> + git -C pm-push-eq checkout --detach &&
> +
> + git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> + test_when_finished "rm -rf pm-push-branch" &&
> + git clone pm-upstream pm-push-branch &&
> + git -C pm-push-branch remote add fork ../pm-fork &&
> + test_config -C pm-push-branch remote.pushDefault fork &&
> + test_config -C pm-push-branch push.default current &&
> + test_config -C pm-push-branch branch.main.pushRemote origin &&
> + git -C pm-push-branch checkout --detach &&
> +
> + git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> + test_when_finished "rm -rf pm-push-diff" &&
> + git clone pm-upstream pm-push-diff &&
> + git -C pm-push-diff remote add fork ../pm-fork &&
> + test_config -C pm-push-diff remote.pushDefault fork &&
> + test_config -C pm-push-diff push.default current &&
> + git -C pm-push-diff branch topic one-commit &&
> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> + git -C pm-push-diff checkout --detach &&
> +
> + git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> + test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "requires at least one <branch>" err
> +'
> +
> +test_expect_success '--prune-merged takes positional <branch> arguments' '
> + test_when_finished "rm -rf pm-positional" &&
> + git clone pm-upstream pm-positional &&
> + git -C pm-positional remote add fork ../pm-fork &&
> + test_config -C pm-positional remote.pushDefault fork &&
> + test_config -C pm-positional push.default current &&
> + git -C pm-positional branch one one-commit &&
> + git -C pm-positional branch --set-upstream-to=origin/next one &&
> + git -C pm-positional branch two base &&
> + git -C pm-positional branch --set-upstream-to=origin/main two &&
> + git -C pm-positional checkout --detach &&
> +
> + git -C pm-positional branch --prune-merged origin/next origin/main &&
> +
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-09 10:11 ` [PATCH v14 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
2026-06-15 9:46 ` Phillip Wood
@ 2026-06-16 9:59 ` Phillip Wood
2026-06-16 19:15 ` Harald Nordgren
1 sibling, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-16 9:59 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
Carrying on where I left off yesterday ...
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..27ea1319bb 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
> test_grep "requires a value" err
> '
>
> +test_expect_success '--prune-merged: setup' '
> + test_create_repo pm-upstream &&
The rest of this test would be easier to read if we did
(
cd pm-upstream &&
...
)
rather than prefixing every command with "-C pm-upstream"
> + test_commit -C pm-upstream base &&
> + git -C pm-upstream checkout -b next &&
> + test_commit -C pm-upstream one-commit &&
> + test_commit -C pm-upstream two-commit &&
> + git -C pm-upstream branch one HEAD~ &&
> + git -C pm-upstream branch two HEAD &&
> + git -C pm-upstream branch wip main &&
> + git -C pm-upstream checkout main &&
> + test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> + test_when_finished "rm -rf pm-merged" &&
> + git clone pm-upstream pm-merged &&
> + git -C pm-merged remote add fork ../pm-fork &&
> + test_config -C pm-merged remote.pushDefault fork &&
> + test_config -C pm-merged push.default current &&
So we clone upstream and add fork as the default push remote. I find the
pm- prefixes rather distracting. It would be clearer to me if we just
called the repositories "upstream", "fork" and "repo"
> + git -C pm-merged branch one one-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next one &&
> + git -C pm-merged branch two two-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next two &&
Now we set up a couple of local branches with no local commits that
track origin/next which seems a bit odd. Why don't we create local
branches based one origin/next (and origin/main if we're using a
wildcard below) with some local commits like a user would?
> + git -C pm-merged branch --prune-merged "origin/*" &&
Here we delete all the branches whose upstream is on origin - which is
all the branches that we've created so we're not really testing the
safety features.
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
This verifies that the branches are deleted. It would be a good idea to
check the output of command above to check --prune-merged prints what we
expect it to and that it leaves branches with other upstreams.
> +test_expect_success '--prune-merged accepts a literal upstream' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
let's not litter the test directory with a hundred repositories - just
call it "repo" and add remove it with test_when_finished in each test,
or reuse it so we don't waste time cloning and setting up the config
each time (that would mean not using test_config).
> + git -C pm-literal remote add fork ../pm-fork &&
> + test_config -C pm-literal remote.pushDefault fork &&
> + test_config -C pm-literal push.default current &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
Again we're not testing that nothing else is deleted.
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> + git -C pm-union remote add fork ../pm-fork &&
> + test_config -C pm-union remote.pushDefault fork &&
> + test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> + git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next origin/main &&
This is more interesting - we don't need the test for a single literal
upstream if we're doing this. Again we need to test the safety features.
As these are integration tests you can do that at the same time as
testing that some branches are removed - you don't need so many separate
(expensive) tests.
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> + git clone pm-upstream pm-local &&
> + git -C pm-local remote add fork ../pm-fork &&
> + test_config -C pm-local remote.pushDefault fork &&
> + test_config -C pm-local push.default current &&
> + git -C pm-local checkout -b trunk &&
> + git -C pm-local branch one one-commit &&
> + git -C pm-local branch --set-upstream-to=trunk one &&
> + git -C pm-local merge --ff-only one-commit &&
> +
> + git -C pm-local branch --prune-merged trunk &&
Can't we test a local upstream at the same time as a remote upstream and
save a test case?
> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + git -C pm-unmerged remote add fork ../pm-fork &&
> + test_config -C pm-unmerged remote.pushDefault fork &&
> + test_config -C pm-unmerged push.default current &&
> + git -C pm-unmerged checkout -b wip origin/wip &&
> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-unmerged local-only &&
> + git -C pm-unmerged checkout - &&
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
I'm always suspicious of test_grep when we know what the output should
look like - it might be better to use test_cmp. This test does not check
that we also delete branches that are merged when we see one that isn't.
I'm going to stop here - the tests I've read seem to me to be too much
like unit tests checking one aspect of the implementation in isolation
rather than checking that the whole feature works as expected.
Thanks
Phillip
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> + test_when_finished "rm -rf pm-nohead" &&
> + git clone pm-upstream pm-nohead &&
> + git -C pm-nohead remote add fork ../pm-fork &&
> + test_config -C pm-nohead remote.pushDefault fork &&
> + test_config -C pm-nohead push.default current &&
> + git -C pm-nohead branch topic one-commit &&
> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> + test_grep ! "not yet merged to HEAD" err &&
> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> + test_when_finished "rm -rf pm-upstream-gone" &&
> + git clone pm-upstream pm-upstream-gone &&
> + git -C pm-upstream-gone remote add fork ../pm-fork &&
> + test_config -C pm-upstream-gone remote.pushDefault fork &&
> + test_config -C pm-upstream-gone push.default current &&
> + git -C pm-upstream-gone branch one one-commit &&
> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> + test_when_finished "rm -rf pm-head" &&
> + git clone pm-upstream pm-head &&
> + git -C pm-head remote add fork ../pm-fork &&
> + test_config -C pm-head remote.pushDefault fork &&
> + test_config -C pm-head push.default current &&
> + git -C pm-head checkout -b one one-commit &&
> + git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-head branch --prune-merged "origin/*" &&
> +
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> + test_when_finished "rm -rf pm-push-eq" &&
> + git clone pm-upstream pm-push-eq &&
> + git -C pm-push-eq checkout --detach &&
> +
> + git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> + test_when_finished "rm -rf pm-push-branch" &&
> + git clone pm-upstream pm-push-branch &&
> + git -C pm-push-branch remote add fork ../pm-fork &&
> + test_config -C pm-push-branch remote.pushDefault fork &&
> + test_config -C pm-push-branch push.default current &&
> + test_config -C pm-push-branch branch.main.pushRemote origin &&
> + git -C pm-push-branch checkout --detach &&
> +
> + git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> + test_when_finished "rm -rf pm-push-diff" &&
> + git clone pm-upstream pm-push-diff &&
> + git -C pm-push-diff remote add fork ../pm-fork &&
> + test_config -C pm-push-diff remote.pushDefault fork &&
> + test_config -C pm-push-diff push.default current &&
> + git -C pm-push-diff branch topic one-commit &&
> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> + git -C pm-push-diff checkout --detach &&
> +
> + git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> + test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "requires at least one <branch>" err
> +'
> +
> +test_expect_success '--prune-merged takes positional <branch> arguments' '
> + test_when_finished "rm -rf pm-positional" &&
> + git clone pm-upstream pm-positional &&
> + git -C pm-positional remote add fork ../pm-fork &&
> + test_config -C pm-positional remote.pushDefault fork &&
> + test_config -C pm-positional push.default current &&
> + git -C pm-positional branch one one-commit &&
> + git -C pm-positional branch --set-upstream-to=origin/next one &&
> + git -C pm-positional branch two base &&
> + git -C pm-positional branch --set-upstream-to=origin/main two &&
> + git -C pm-positional checkout --detach &&
> +
> + git -C pm-positional branch --prune-merged origin/next origin/main &&
> +
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-16 9:59 ` Phillip Wood
@ 2026-06-16 19:15 ` Harald Nordgren
2026-06-18 13:42 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-16 19:15 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> > index 4e7deddc04..27ea1319bb 100755
> > --- a/t/t3200-branch.sh
> > +++ b/t/t3200-branch.sh
> > @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
> > test_grep "requires a value" err
> > '
> >
> > +test_expect_success '--prune-merged: setup' '
> > + test_create_repo pm-upstream &&
>
> The rest of this test would be easier to read if we did
>
> (
> cd pm-upstream &&
> ...
> )
>
> rather than prefixing every command with "-C pm-upstream"
I feel like the discussion to nest or not to nest has come up many
times in other topics as well. I don't feel strongly about either way,
but I just want to flag that if I change it now, another reviewer
might ask me to change it back later.
Should the rules be to nest inside of setup functions (and helpers?)
but not inside the actual tests?
> > + test_commit -C pm-upstream base &&
> > + git -C pm-upstream checkout -b next &&
> > + test_commit -C pm-upstream one-commit &&
> > + test_commit -C pm-upstream two-commit &&
> > + git -C pm-upstream branch one HEAD~ &&
> > + git -C pm-upstream branch two HEAD &&
> > + git -C pm-upstream branch wip main &&
> > + git -C pm-upstream checkout main &&
> > + test_create_repo pm-fork
> > +'
> > +
> > +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> > + test_when_finished "rm -rf pm-merged" &&
> > + git clone pm-upstream pm-merged &&
> > + git -C pm-merged remote add fork ../pm-fork &&
> > + test_config -C pm-merged remote.pushDefault fork &&
> > + test_config -C pm-merged push.default current &&
>
> So we clone upstream and add fork as the default push remote. I find the
> pm- prefixes rather distracting. It would be clearer to me if we just
> called the repositories "upstream", "fork" and "repo"
Good point.
> > + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> > +'
> > +
> > +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> > + test_when_finished "rm -rf pm-unmerged" &&
> > + git clone pm-upstream pm-unmerged &&
> > + git -C pm-unmerged remote add fork ../pm-fork &&
> > + test_config -C pm-unmerged remote.pushDefault fork &&
> > + test_config -C pm-unmerged push.default current &&
> > + git -C pm-unmerged checkout -b wip origin/wip &&
> > + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> > + test_commit -C pm-unmerged local-only &&
> > + git -C pm-unmerged checkout - &&
> > +
> > + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> > + test_grep "not fully merged" err &&
> > + test_grep ! "If you are sure you want to delete it" err &&
>
> I'm always suspicious of test_grep when we know what the output should
> look like - it might be better to use test_cmp. This test does not check
> that we also delete branches that are merged when we see one that isn't.
>
> I'm going to stop here - the tests I've read seem to me to be too much
> like unit tests checking one aspect of the implementation in isolation
> rather than checking that the whole feature works as expected.
I'll respond to the rest here: Excellent points regarding the testing
aboce, I will take a look at doing this.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-16 19:15 ` Harald Nordgren
@ 2026-06-18 13:42 ` Phillip Wood
2026-06-18 16:08 ` Junio C Hamano
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-18 13:42 UTC (permalink / raw)
To: Harald Nordgren
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
Hi Harald
On 16/06/2026 20:15, Harald Nordgren wrote:
>>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
>>> index 4e7deddc04..27ea1319bb 100755
>>> --- a/t/t3200-branch.sh
>>> +++ b/t/t3200-branch.sh
>>> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '
>>> test_grep "requires a value" err
>>> '
>>>
>>> +test_expect_success '--prune-merged: setup' '
>>> + test_create_repo pm-upstream &&
>>
>> The rest of this test would be easier to read if we did
>>
>> (
>> cd pm-upstream &&
>> ...
>> )
>>
>> rather than prefixing every command with "-C pm-upstream"
>
> I feel like the discussion to nest or not to nest has come up many
> times in other topics as well. I don't feel strongly about either way,
> but I just want to flag that if I change it now, another reviewer
> might ask me to change it back later.
>
> Should the rules be to nest inside of setup functions (and helpers?)
> but not inside the actual tests?
I think it depends on how many commands you're running in a row in the
same directory. In this case we're running quite a few commands so it
seems clearer to use a subshell. In the later tests we're switching
between repositories and running fewer commands in each one so it is
less clear that using subshells is clearer. Also later on we're using
test_config() which I don't think works in a subshell because it relies
on test_when_finished().
One thing I've just thought of related to this patch is whether we want
to protect branches that are the upstreams of branches that are not
slated for deletion. With stacked branches it is possible that a branch
has been merged but has other branches stacked on top of it that have
not been merged. If we build an strset of branches that we want to
delete, then loop over all branches and if there are any that are not in
the to be deleted set which have their upstream in that set we'd remove
the upstream branch from the set. Once we've done that we can convert
the set to an strvec to pass to delete_branches()
Thanks
Phillip
>
>>> + test_commit -C pm-upstream base &&
>>> + git -C pm-upstream checkout -b next &&
>>> + test_commit -C pm-upstream one-commit &&
>>> + test_commit -C pm-upstream two-commit &&
>>> + git -C pm-upstream branch one HEAD~ &&
>>> + git -C pm-upstream branch two HEAD &&
>>> + git -C pm-upstream branch wip main &&
>>> + git -C pm-upstream checkout main &&
>>> + test_create_repo pm-fork
>>> +'
>>> +
>>> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
>>> + test_when_finished "rm -rf pm-merged" &&
>>> + git clone pm-upstream pm-merged &&
>>> + git -C pm-merged remote add fork ../pm-fork &&
>>> + test_config -C pm-merged remote.pushDefault fork &&
>>> + test_config -C pm-merged push.default current &&
>>
>> So we clone upstream and add fork as the default push remote. I find the
>> pm- prefixes rather distracting. It would be clearer to me if we just
>> called the repositories "upstream", "fork" and "repo"
>
> Good point.
>
>>> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
>>> +'
>>> +
>>> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
>>> + test_when_finished "rm -rf pm-unmerged" &&
>>> + git clone pm-upstream pm-unmerged &&
>>> + git -C pm-unmerged remote add fork ../pm-fork &&
>>> + test_config -C pm-unmerged remote.pushDefault fork &&
>>> + test_config -C pm-unmerged push.default current &&
>>> + git -C pm-unmerged checkout -b wip origin/wip &&
>>> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
>>> + test_commit -C pm-unmerged local-only &&
>>> + git -C pm-unmerged checkout - &&
>>> +
>>> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
>>> + test_grep "not fully merged" err &&
>>> + test_grep ! "If you are sure you want to delete it" err &&
>>
>> I'm always suspicious of test_grep when we know what the output should
>> look like - it might be better to use test_cmp. This test does not check
>> that we also delete branches that are merged when we see one that isn't.
>>
>> I'm going to stop here - the tests I've read seem to me to be too much
>> like unit tests checking one aspect of the implementation in isolation
>> rather than checking that the whole feature works as expected.
>
> I'll respond to the rest here: Excellent points regarding the testing
> aboce, I will take a look at doing this.
>
>
> Harald
>
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-18 13:42 ` Phillip Wood
@ 2026-06-18 16:08 ` Junio C Hamano
2026-06-19 13:13 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Junio C Hamano @ 2026-06-18 16:08 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Phillip Wood <phillip.wood123@gmail.com> writes:
> One thing I've just thought of related to this patch is whether we want
> to protect branches that are the upstreams of branches that are not
> slated for deletion. With stacked branches it is possible that a branch
> has been merged but has other branches stacked on top of it that have
> not been merged.
An interesting point. We do have "this topic is built on the result
of merging these other topics into main" and I expect the practice
is wide spread. These base topics may graduate first, but other
topics may still be updated.
But when you rewrite these other topics, wouldn't you leave their
bases untouched? IOW, a new iteration (i.e. "rebase -i") would
reuse the base that was used in an earlier iteration, i.e. the
result of an earlier merge of the other topics, some of which might
have been pruned since then, into an older 'main', so it is OK to
lose these other topics once they have graduated, simply because you
wouldn't be recreating the merge that you used as the base of this
remaining topic, no?
Or am I missing something?
Thanks.
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-18 16:08 ` Junio C Hamano
@ 2026-06-19 13:13 ` Phillip Wood
2026-06-19 15:42 ` Junio C Hamano
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-19 13:13 UTC (permalink / raw)
To: Junio C Hamano
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
On 18/06/2026 17:08, Junio C Hamano wrote:
> Phillip Wood <phillip.wood123@gmail.com> writes:
>
>> One thing I've just thought of related to this patch is whether we want
>> to protect branches that are the upstreams of branches that are not
>> slated for deletion. With stacked branches it is possible that a branch
>> has been merged but has other branches stacked on top of it that have
>> not been merged.
>
> An interesting point. We do have "this topic is built on the result
> of merging these other topics into main" and I expect the practice
> is wide spread. These base topics may graduate first, but other
> topics may still be updated.
>
> But when you rewrite these other topics, wouldn't you leave their
> bases untouched? IOW, a new iteration (i.e. "rebase -i") would
> reuse the base that was used in an earlier iteration, i.e. the
> result of an earlier merge of the other topics, some of which might
> have been pruned since then, into an older 'main', so it is OK to
> lose these other topics once they have graduated, simply because you
> wouldn't be recreating the merge that you used as the base of this
> remaining topic, no?
>
> Or am I missing something?
I was thinking that if I have feature1 with upstream origin/master and
feature2 with upstream feautre1, then once feature1 is merged I'd still
like "git log @{u}.." and "git rebase" without an explicit upstream to
work when feature2 is checked out. If "git branch --prune-merged
origin/master" deletes feautre1 then those commands stop working. Maybe
it would be sensible to update feature2's upstream once feature1 is
merged (which I think is what you're saying above) but do we really want
to force the user to do that by deleting feature1?
Thanks
Phillip
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-19 13:13 ` Phillip Wood
@ 2026-06-19 15:42 ` Junio C Hamano
2026-06-19 16:01 ` Junio C Hamano
0 siblings, 1 reply; 189+ messages in thread
From: Junio C Hamano @ 2026-06-19 15:42 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Phillip Wood <phillip.wood123@gmail.com> writes:
> I was thinking that if I have feature1 with upstream origin/master and
> feature2 with upstream feautre1, then once feature1 is merged I'd still
> like "git log @{u}.." and "git rebase" without an explicit upstream to
> work when feature2 is checked out. If "git branch --prune-merged
> origin/master" deletes feautre1 then those commands stop working. Maybe
> it would be sensible to update feature2's upstream once feature1 is
> merged (which I think is what you're saying above) but do we really want
> to force the user to do that by deleting feature1?
Ahh, reference with @{upstream}. Yeah, that _does_ make sense.
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-19 15:42 ` Junio C Hamano
@ 2026-06-19 16:01 ` Junio C Hamano
2026-06-20 9:04 ` Harald Nordgren
2026-06-22 9:07 ` Phillip Wood
0 siblings, 2 replies; 189+ messages in thread
From: Junio C Hamano @ 2026-06-19 16:01 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Junio C Hamano <gitster@pobox.com> writes:
> Phillip Wood <phillip.wood123@gmail.com> writes:
>
>> I was thinking that if I have feature1 with upstream origin/master and
>> feature2 with upstream feautre1, then once feature1 is merged I'd still
>> like "git log @{u}.." and "git rebase" without an explicit upstream to
>> work when feature2 is checked out. If "git branch --prune-merged
>> origin/master" deletes feautre1 then those commands stop working. Maybe
>> it would be sensible to update feature2's upstream once feature1 is
>> merged (which I think is what you're saying above) but do we really want
>> to force the user to do that by deleting feature1?
>
> Ahh, reference with @{upstream}. Yeah, that _does_ make sense.
Oops, hit [Send] too soon.
This reminds me of the discussion on a separate thread on "history
squash" and "history drop", that squashes a segment of history
o---A---B---C
into a single commit (i.e., "We want to remove A and B and keep C" or
"We want to squash A, B, and C into a single commit")
o---X
It is natural to consider "X" in the new history a counterpart of
"C" in the original history, but what should happen to refs that
used to point at A and B? Sometimes it may make sense to drop the
refs that point into commits that disappear in the final history,
sometimes it may make sense to move them to point at the surviving
commit, sometimes it may make sense to leave them as-is (i.e., refs
still point at A and B in the old history, preventing them from
getting gc'ed).
And the safest behaviour would be to ask the user, i.e., notice that
we would get into this iffy situation and abort, asking the user
what to do.
Now that safest behaviour in the other topic translates to
- Notice that a branch that is deleted (because it itself is
merged) is still depended upon by being @{upstream} of somebody
else, and when it happens, fail the operation (i.e., do not
delete the branch).
What are the viable choices we can offer to the user in such a
situation? I think there are a few viable choices.
- Make the dependent branch no longer depend on anything. Asking
feature2@{upstream} would fail.
- Move the @{upstream} of feature2 to the branch that "merged"
feature1 and caused its removal. Asking feature2@{upstream}
would answer origin/master, which feature1 was removed after
getting merged.
There may be others. And there may be relationship similar to
feature1 vs feature2 that is not @{upstream} but something else that
makes a branch still "depend on" the other branch getting deleted.
Do we also need the same safety around "git branch -d feature1" by
the way? The "-d" option with safety checks the same "is feature1
already merged (to its upstream)?" condition, so it can protect the
feature2 branch the same way, by saying either "oops, you cannot
delete feature1 because you still have other branches like feature2
that depend on it", or "ok, featur2 used to depend on feature1, but
because we are deleting feature1 based on it being in origin/master,
we will make feature2 depend on origin/master from now on".
Thanks.
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-19 16:01 ` Junio C Hamano
@ 2026-06-20 9:04 ` Harald Nordgren
2026-06-21 18:46 ` Harald Nordgren
2026-06-22 9:25 ` Phillip Wood
2026-06-22 9:07 ` Phillip Wood
1 sibling, 2 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-20 9:04 UTC (permalink / raw)
To: Junio C Hamano
Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
> - Move the @{upstream} of feature2 to the branch that "merged"
> feature1 and caused its removal. Asking feature2@{upstream}
> would answer origin/master, which feature1 was removed after
> getting merged.
I think this is a strong option.
As a side note: I was annoyed before when GitHub didn't re-assign base
automatically when doing stacked PR's, so merging in the first branch
caused developers to merge in the second PR into essentially a dead
feature branch instead of master, if they forgot to manually change
it. But I think GitHub has fixed this now so the second PR gets its
base changed to default branch.
Two caveats:
- How to handle recursion: b1 has b2 as upstream and b2 has b3 as
upstream, and both b2 and b3 have been merged? Not good if it's just
luck which order the branches get walked, but also we don't want to
have to do many passes, two passes is not even guaranteed to be
enough.
- What about when b3 has itself as upstream? I guess then we can just
remove the upstream of b2. Overall, I don't think it's a huge problem
when a branch gets no upstream, so maybe just warn about it.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-20 9:04 ` Harald Nordgren
@ 2026-06-21 18:46 ` Harald Nordgren
2026-06-22 9:09 ` Phillip Wood
2026-06-22 9:25 ` Phillip Wood
1 sibling, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-21 18:46 UTC (permalink / raw)
To: Junio C Hamano
Cc: Phillip Wood, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Looking into this more and attempting to implement the logic for
re-assigning the upstream, it becomes quite a lot of code.
Maybe an easier way forward now is to avoid deleting these cases. We
can always add the re-assigning logic later on without breaking
backward compatibility.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-21 18:46 ` Harald Nordgren
@ 2026-06-22 9:09 ` Phillip Wood
2026-06-22 9:28 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-22 9:09 UTC (permalink / raw)
To: Harald Nordgren, Junio C Hamano
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
Hi Harald
On 21/06/2026 19:46, Harald Nordgren wrote:
> Looking into this more and attempting to implement the logic for
> re-assigning the upstream, it becomes quite a lot of code.
>
> Maybe an easier way forward now is to avoid deleting these cases. We
> can always add the re-assigning logic later on without breaking
> backward compatibility.
Not deleting the branch is certainly safest and should be fairly easy to
implement. Adding an option to reassign the upstream later sounds fine
to me.
Thanks
Phillip
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-22 9:09 ` Phillip Wood
@ 2026-06-22 9:28 ` Phillip Wood
2026-06-22 9:37 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-22 9:28 UTC (permalink / raw)
To: Harald Nordgren, Junio C Hamano
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
On 22/06/2026 10:09, Phillip Wood wrote:
> Hi Harald
>
> On 21/06/2026 19:46, Harald Nordgren wrote:
>> Looking into this more and attempting to implement the logic for
>> re-assigning the upstream, it becomes quite a lot of code.
Having re-read you previous message I'm coming round to the idea of
clearing the upstream of branches that have been merged but cannot be
deleted because they are the upstream of an unmerged branch. Is that
easier than reassigning the upstream?
Thanks
Phillip
>> Maybe an easier way forward now is to avoid deleting these cases. We
>> can always add the re-assigning logic later on without breaking
>> backward compatibility.
>
> Not deleting the branch is certainly safest and should be fairly easy to
> implement. Adding an option to reassign the upstream later sounds fine
> to me.
>
> Thanks
>
> Phillip
>
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-22 9:28 ` Phillip Wood
@ 2026-06-22 9:37 ` Harald Nordgren
2026-06-22 9:57 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-22 9:37 UTC (permalink / raw)
To: Phillip Wood
Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Hi! I implemented this in v17.
Harald
On Mon, Jun 22, 2026 at 11:28 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> On 22/06/2026 10:09, Phillip Wood wrote:
> > Hi Harald
> >
> > On 21/06/2026 19:46, Harald Nordgren wrote:
> >> Looking into this more and attempting to implement the logic for
> >> re-assigning the upstream, it becomes quite a lot of code.
>
> Having re-read you previous message I'm coming round to the idea of
> clearing the upstream of branches that have been merged but cannot be
> deleted because they are the upstream of an unmerged branch. Is that
> easier than reassigning the upstream?
>
> Thanks
>
> Phillip
>
> >> Maybe an easier way forward now is to avoid deleting these cases. We
> >> can always add the re-assigning logic later on without breaking
> >> backward compatibility.
> >
> > Not deleting the branch is certainly safest and should be fairly easy to
> > implement. Adding an option to reassign the upstream later sounds fine
> > to me.
> >
> > Thanks
> >
> > Phillip
> >
>
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-22 9:37 ` Harald Nordgren
@ 2026-06-22 9:57 ` Phillip Wood
2026-06-22 10:52 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-22 9:57 UTC (permalink / raw)
To: Harald Nordgren
Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
On 22/06/2026 10:37, Harald Nordgren wrote:
> Hi! I implemented this in v17.
That seems to preserve all the upstreams, rather than preserving the
upstreams of unmerged branches and clearing the upstream setting of
merged branches. It also builds a list of upstream branches rather than
just pruning the set of to-be-deleted branches in the
refs_for_each_branch_ref() callback which seems a bit wasteful.
Thanks
Phillip
>
> Harald
>
> On Mon, Jun 22, 2026 at 11:28 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>>
>> On 22/06/2026 10:09, Phillip Wood wrote:
>>> Hi Harald
>>>
>>> On 21/06/2026 19:46, Harald Nordgren wrote:
>>>> Looking into this more and attempting to implement the logic for
>>>> re-assigning the upstream, it becomes quite a lot of code.
>>
>> Having re-read you previous message I'm coming round to the idea of
>> clearing the upstream of branches that have been merged but cannot be
>> deleted because they are the upstream of an unmerged branch. Is that
>> easier than reassigning the upstream?
>>
>> Thanks
>>
>> Phillip
>>
>>>> Maybe an easier way forward now is to avoid deleting these cases. We
>>>> can always add the re-assigning logic later on without breaking
>>>> backward compatibility.
>>>
>>> Not deleting the branch is certainly safest and should be fairly easy to
>>> implement. Adding an option to reassign the upstream later sounds fine
>>> to me.
>>>
>>> Thanks
>>>
>>> Phillip
>>>
>>
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-22 9:57 ` Phillip Wood
@ 2026-06-22 10:52 ` Harald Nordgren
0 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-22 10:52 UTC (permalink / raw)
To: Phillip Wood
Cc: Junio C Hamano, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
> clearing the upstream setting of
> merged branches.
Why do we need to do that, they will be deleted anyway since they are merged?
> It also builds a list of upstream branches rather than
> just pruning the set of to-be-deleted branches in the
> refs_for_each_branch_ref() callback which seems a bit wasteful.
Good point.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-20 9:04 ` Harald Nordgren
2026-06-21 18:46 ` Harald Nordgren
@ 2026-06-22 9:25 ` Phillip Wood
1 sibling, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-22 9:25 UTC (permalink / raw)
To: Harald Nordgren, Junio C Hamano
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
Hi Harald
On 20/06/2026 10:04, Harald Nordgren wrote:
>> - Move the @{upstream} of feature2 to the branch that "merged"
>> feature1 and caused its removal. Asking feature2@{upstream}
>> would answer origin/master, which feature1 was removed after
>> getting merged.
>
> I think this is a strong option.
>
> As a side note: I was annoyed before when GitHub didn't re-assign base
> automatically when doing stacked PR's, so merging in the first branch
> caused developers to merge in the second PR into essentially a dead
> feature branch instead of master, if they forgot to manually change
> it. But I think GitHub has fixed this now so the second PR gets its
> base changed to default branch.
>
> Two caveats:
>
> - How to handle recursion: b1 has b2 as upstream and b2 has b3 as
> upstream, and both b2 and b3 have been merged? Not good if it's just
> luck which order the branches get walked, but also we don't want to
> have to do many passes, two passes is not even guaranteed to be
> enough.
I think you only need one pass. For each branch we look at it's upstream
and if it is in the set of branches we want to delete we
1 remove it from the set of branches to be deleted
2 if the upstream of the upstream is in the set of branches to be
deleted goto 1.
Note that we don't need to create a list of all upstream branches, we
can handle it within the refs_for_each_branch_ref() callback.
> - What about when b3 has itself as upstream? I guess then we can just
> remove the upstream of b2. Overall, I don't think it's a huge problem
> when a branch gets no upstream, so maybe just warn about it.
Removing the upstream config of branches that are merged shouldn't cause
too many problems - the user is unlikely to want to rebase a merged
branch and they're unlikely to have it checked out so "git rebase" and
"git log @{u}.." probably does not matter.
So maybe we should change the loop above to only keep the upstream
branch of branches that have not been merged and instead clear the
upstream config of any merged branches we keep because they are an
upstream of another branch.
Thanks
Phillip
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-19 16:01 ` Junio C Hamano
2026-06-20 9:04 ` Harald Nordgren
@ 2026-06-22 9:07 ` Phillip Wood
2026-06-22 12:26 ` Junio C Hamano
1 sibling, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-22 9:07 UTC (permalink / raw)
To: Junio C Hamano
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
On 19/06/2026 17:01, Junio C Hamano wrote:
> Junio C Hamano <gitster@pobox.com> writes:
>
> - Notice that a branch that is deleted (because it itself is
> merged) is still depended upon by being @{upstream} of somebody
> else, and when it happens, fail the operation (i.e., do not
> delete the branch).
Not deleting is definitely the safest option. As Harald has pointed out
we then need to think about what to do with the upstream branch of the
branch we're keeping because it is an upstream of another branch that's
not being deleted. Keeping the whole chain is probably safest.
> What are the viable choices we can offer to the user in such a
> situation? I think there are a few viable choices.
>
> - Make the dependent branch no longer depend on anything. Asking
> feature2@{upstream} would fail.
>
> - Move the @{upstream} of feature2 to the branch that "merged"
> feature1 and caused its removal. Asking feature2@{upstream}
> would answer origin/master, which feature1 was removed after
> getting merged.
The second option is quite tempting. My only concern about doing it
automatically is that it would break "git rebase --fork-point" (which is
the default if you don't specify an upstream). If a user rewrites a
branch, merges it and then "git branch --delete-merged" deletes it and
reassigns the upstream of the branches descended from it, rebasing those
branches would pull in some stale commits.
> There may be others. And there may be relationship similar to
> feature1 vs feature2 that is not @{upstream} but something else that
> makes a branch still "depend on" the other branch getting deleted.
>
> Do we also need the same safety around "git branch -d feature1" by
> the way? The "-d" option with safety checks the same "is feature1
> already merged (to its upstream)?" condition, so it can protect the
> feature2 branch the same way, by saying either "oops, you cannot
> delete feature1 because you still have other branches like feature2
> that depend on it", or "ok, featur2 used to depend on feature1, but
> because we are deleting feature1 based on it being in origin/master,
> we will make feature2 depend on origin/master from now on".
I think handling branches that are the upstream for another branch the
same way for "git branch -d" and "git branch --prune-merged" is a good idea.
Thanks
Phillip
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v14 4/6] branch: add --prune-merged <branch>
2026-06-22 9:07 ` Phillip Wood
@ 2026-06-22 12:26 ` Junio C Hamano
0 siblings, 0 replies; 189+ messages in thread
From: Junio C Hamano @ 2026-06-22 12:26 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren, Harald Nordgren via GitGitGadget, git,
Kristoffer Haugsbakk, Johannes Sixt
Phillip Wood <phillip.wood123@gmail.com> writes:
> On 19/06/2026 17:01, Junio C Hamano wrote:
>> Junio C Hamano <gitster@pobox.com> writes:
>>
>> - Notice that a branch that is deleted (because it itself is
>> merged) is still depended upon by being @{upstream} of somebody
>> else, and when it happens, fail the operation (i.e., do not
>> delete the branch).
>
> Not deleting is definitely the safest option. As Harald has pointed out
> we then need to think about what to do with the upstream branch of the
> branch we're keeping because it is an upstream of another branch that's
> not being deleted. Keeping the whole chain is probably safest.
You're right.
> I think handling branches that are the upstream for another branch the
> same way for "git branch -d" and "git branch --prune-merged" is a good idea.
Yeah. The latter should be a short-hand for
for each branch
do
if branch is merged to its upstream
then
remember to remove it
fi
done
now remove those we scheduled to remove with "git branch -d".
so it would be clean if we can explain that philosophically the
safety lies within "gir branch -d".
Thanks.
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-09 10:11 ` [PATCH v14 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-16 9:57 ` Phillip Wood
2026-06-09 10:11 ` [PATCH v14 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from
"git branch --prune-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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.<name>.pruneMerged`::
+ If set to `false`, branch _<name>_ 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 fdaccc9662..5c43dc55a8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -217,9 +217,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 (`<branch>@{push}`) equals its
+worktree; its push destination (`<branch>@{push}`) equals its
upstream (`<branch>@{upstream}`), so it cannot be distinguished
-from a freshly pulled trunk that just looks "fully merged".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.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 af37a0ceb7..52a0371292 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -741,6 +741,8 @@ static int prune_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
+ struct strbuf key = STRBUF_INIT;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
continue;
@@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+ !opt_out) {
+ if (!quiet)
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
+ continue;
+ }
+ strbuf_release(&key);
+
strvec_push(&deletable, short_name);
}
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 27ea1319bb..3f7b1fc3d6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '
test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
+ test_config -C pm-optout remote.pushDefault fork &&
+ test_config -C pm-optout push.default current &&
+ git -C pm-optout branch one one-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next one &&
+ git -C pm-optout branch two two-commit &&
+ git -C pm-optout branch --set-upstream-to=origin/next two &&
+ test_config -C pm-optout branch.one.pruneMerged false &&
+
+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+ git -C pm-optout rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+ test_grep "Skipping .one." err
+'
+
+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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out
2026-06-09 10:11 ` [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-16 9:57 ` Phillip Wood
0 siblings, 0 replies; 189+ messages in thread
From: Phillip Wood @ 2026-06-16 9:57 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> Setting branch.<name>.pruneMerged=false exempts that branch from
> "git branch --prune-merged", which is useful for a topic you want
> to keep developing after an early round of it has been merged
> upstream. Unless --quiet is given, each skip is reported so the
> user knows why their topic was kept.
Sounds good
> @@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
> if (!push || !strcmp(push, upstream))
> continue;
>
> + strbuf_addf(&key, "branch.%s.prunemerged", short_name);
> + if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
> + !opt_out) {
> + if (!quiet)
> + fprintf(stderr,
> + _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
> + short_name, short_name);
> + strbuf_release(&key);
> + continue;
> + }
> + strbuf_release(&key);
As this is in a loop we don't want to free the buffer on each iteration,
only at the end. You should call strbuf_reset() just before
strbuf_addf() above and then move this call to strbuf_release() out of
the loop.
> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> + test_when_finished "rm -rf pm-optout" &&
> + git clone pm-upstream pm-optout &&
> + git -C pm-optout remote add fork ../pm-fork &&
> + test_config -C pm-optout remote.pushDefault fork &&
> + test_config -C pm-optout push.default current &&
> + git -C pm-optout branch one one-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next one &&
> + git -C pm-optout branch two two-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next two &&
> + test_config -C pm-optout branch.one.pruneMerged false &&
> +
> + git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> +
> + git -C pm-optout rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
> + test_grep "Skipping .one." err
Do we really need a whole new setup to test this - can't we just add a
protected branch to an existing test?
Thanks
Phillip
> +
> +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
> + test_when_finished "rm -rf pm-optout-d" &&
> + git clone pm-upstream pm-optout-d &&
> + git -C pm-optout-d branch one one-commit &&
> + git -C pm-optout-d branch --set-upstream-to=origin/next one &&
> + test_config -C pm-optout-d branch.one.pruneMerged false &&
> +
> + git -C pm-optout-d branch -d one &&
> + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v14 6/6] branch: add --dry-run for --prune-merged
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-09 10:11 ` [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-09 10:11 ` Harald Nordgren via GitGitGadget
2026-06-16 9:57 ` Phillip Wood
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
6 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --prune-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 13 ++++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5c43dc55a8..1f49a831fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --prune-merged <branch>...
+git branch [--dry-run] --prune-merged <branch>...
DESCRIPTION
-----------
@@ -226,6 +226,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 52a0371292..7c52a88af2 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
static int prune_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct ref_filter filter = REF_FILTER_INIT;
@@ -777,7 +777,8 @@ static int prune_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES,
DELETE_BRANCH_WARN_ONLY |
DELETE_BRANCH_NO_HEAD_FALLBACK |
- (quiet ? DELETE_BRANCH_QUIET : 0));
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
ref_array_clear(&candidates);
@@ -827,6 +828,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 prune_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ -882,6 +884,8 @@ int cmd_branch(int argc,
N_("edit the description for the branch")),
OPT_BOOL(0, "prune-merged", &prune_merged,
N_("delete local branches whose upstream matches <branch> and is merged")),
+ 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")),
@@ -944,6 +948,9 @@ int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
+ if (dry_run && !prune_merged)
+ 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"));
@@ -983,7 +990,7 @@ int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (prune_merged) {
- ret = prune_merged_branches(argc, argv, quiet);
+ ret = prune_merged_branches(argc, argv, 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 3f7b1fc3d6..305c0141fc 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+ 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v14 6/6] branch: add --dry-run for --prune-merged
2026-06-09 10:11 ` [PATCH v14 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-16 9:57 ` Phillip Wood
2026-06-16 18:28 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-16 9:57 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
Hi Harald
On 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> With --dry-run, --prune-merged prints the local branches it would
> delete, one "Would delete branch <name>" line each, and exits
> without touching any ref. The same filtering applies, so the output
> is exactly the set that the real run would delete.
I can see this being very useful.
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 52a0371292..7c52a88af2 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
> }
>
> static int prune_merged_branches(int argc, const char **argv,
> - int quiet)
> + int quiet, int dry_run)
Let's not start adding multiple boolean augments - use a flags argument
like we do for delete_branches() - if you get feedback on one patch you
should think about whether it applies later in the series as well. The
rest of the implementation looks good.
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 3f7b1fc3d6..305c0141fc 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
> test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> '
>
> +test_expect_success '--prune-merged --dry-run lists but does not delete' '
A good way to test --dry-run would be to add it to an existing test
before calling --prune-merged without --dry-run.
Thanks
Phillip
> + test_when_finished "rm -rf pm-dry" &&
> + git clone pm-upstream pm-dry &&
> + git -C pm-dry remote add fork ../pm-fork &&
> + test_config -C pm-dry remote.pushDefault fork &&
> + test_config -C pm-dry push.default current &&
> + git -C pm-dry branch one one-commit &&
> + git -C pm-dry branch --set-upstream-to=origin/next one &&
> + git -C pm-dry branch two two-commit &&
> + git -C pm-dry branch --set-upstream-to=origin/next two &&
> +
> + git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
> + test_grep "Would delete branch one " actual &&
> + test_grep "Would delete branch two " actual &&
> +
> + git -C pm-dry rev-parse --verify refs/heads/one &&
> + git -C pm-dry rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
> + test_when_finished "rm -rf pm-dry-mixed" &&
> + git clone pm-upstream pm-dry-mixed &&
> + git -C pm-dry-mixed remote add fork ../pm-fork &&
> + test_config -C pm-dry-mixed remote.pushDefault fork &&
> + test_config -C pm-dry-mixed push.default current &&
> + git -C pm-dry-mixed checkout -b wip origin/next &&
> + git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-dry-mixed local-only &&
> + git -C pm-dry-mixed checkout - &&
> + git -C pm-dry-mixed branch merged one-commit &&
> + git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
> +
> + git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
> + test_grep "Would delete branch merged" out &&
> + test_grep ! "Would delete branch wip" out &&
> + git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
> + git -C pm-dry-mixed rev-parse --verify refs/heads/merged
> +'
> +
> +test_expect_success '--dry-run without --prune-merged is rejected' '
> + test_must_fail git -C forked branch --dry-run 2>err &&
> + test_grep "requires --prune-merged" err
> +'
> +
> test_done
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v14 6/6] branch: add --dry-run for --prune-merged
2026-06-16 9:57 ` Phillip Wood
@ 2026-06-16 18:28 ` Harald Nordgren
0 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-16 18:28 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
> > With --dry-run, --prune-merged prints the local branches it would
> > delete, one "Would delete branch <name>" line each, and exits
> > without touching any ref. The same filtering applies, so the output
> > is exactly the set that the real run would delete.
>
> I can see this being very useful.
Great to hear and thanks for taking the time to review this! Much appreciated!
> > static int prune_merged_branches(int argc, const char **argv,
> > - int quiet)
> > + int quiet, int dry_run)
>
> Let's not start adding multiple boolean augments - use a flags argument
> like we do for delete_branches() - if you get feedback on one patch you
> should think about whether it applies later in the series as well. The
> rest of the implementation looks good.
I'm trying to generalize all feedback, but sometimes I miss things.
Thanks for pointing it out!
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v15 0/7] branch: delete-merged
2026-06-09 10:11 ` [PATCH v14 0/6] branch: prune-merged Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-09 10:11 ` [PATCH v14 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (8 more replies)
6 siblings, 9 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
* Renamed --prune-merged to --delete-merged throughout. Not necessarily
final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of
warning.
* --forked now accepts a bare remote name (e.g. origin) for the branch
origin/HEAD points at using DWIM.
* Initialized the delete_branches() flag locals where declared. Only force
stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f,
--forked <pattern> <branch> coverage), renamed the misleading trunk
fixture, and replaced the misnamed detached branch with git checkout
--detach.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 43 +++-
builtin/branch.c | 184 ++++++++++++---
ref-filter.c | 70 ++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 387 +++++++++++++++++++++++++++++++
6 files changed, 673 insertions(+), 28 deletions(-)
base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v15
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v15
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v14:
1: 7383872f4b ! 1: da741b5ea7 branch: add --forked filter for --list mode
@@ Commit message
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
- can be a ref (e.g. "origin/main", "master") or a shell glob
+ 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:
@@ Commit message
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
- This is the building block for --prune-merged, which deletes the
+ This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: superproject's "origin/main", but tracks the subm
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
-+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
-+ option can be repeated to widen the filter. Implies `--list`.
++ `master`), a remote name like `origin` for the branch its
++ `origin/HEAD` points at, or a shell-style glob (e.g.
++ `'origin/*'`). The option can be repeated to widen the
++ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ 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 remote add -f other ../forked-other &&
++ git -C forked remote set-head origin one &&
+ 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
++ git -C forked branch --track local-onbase local-base &&
++
++ git -C forked checkout local-one &&
++ test_commit -C forked --no-tag local-one-work local-one.t &&
++ git -C forked checkout local-foreign &&
++ test_commit -C forked --no-tag local-foreign-work local-foreign.t &&
++ git -C forked checkout --detach
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
-+ echo local-trunk >expect &&
++ echo local-onbase >expect &&
+ test_cmp expect actual
+'
+
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-foreign
-+ local-trunk
++ local-onbase
+ EOF
+ test_cmp expect actual
+'
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+'
+
+test_expect_success '--forked composes with --no-merged' '
-+ test_when_finished "git -C forked checkout detached" &&
++ 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 \
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+ test_must_fail git -C forked branch --forked 2>err &&
+ test_grep "requires a value" err
+'
++
++test_expect_success '--forked <remote> uses the branch <remote>/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 <pattern> 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
2: 7ef9502e01 ! 2: 91c35f10cc branch: let delete_branches warn instead of error on bulk refusal
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: let delete_branches warn instead of error on bulk refusal
+ branch: convert delete_branches() to a flags argument
- Add a warn-only mode to delete_branches() and check_branch_commit()
- so a bulk caller can report branches that are not fully merged as a
- short warning and carry on, rather than erroring with the longer
- "use 'git branch -D'" advice that the plain "git branch -d" path
- emits. Existing callers are unaffected.
+ delete_branches() and check_branch_commit() take a pair of int
+ booleans (force and quiet) that the next commits would grow further.
+ Replace them with a single "unsigned int flags" argument and an
+ enum, splitting the bits back into named bool locals so the body
+ keeps reading the same named values.
+
+ No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ builtin/branch.c: 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_WARN_ONLY = (1 << 2),
+};
+
static int check_branch_commit(const char *branchname, const char *refname,
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
- int kinds, int force)
+ int kinds, unsigned int flags)
{
-+ int force = flags & DELETE_BRANCH_FORCE;
++ 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);
- 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 (flags & DELETE_BRANCH_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;
@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
strbuf_release(&buf);
}
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
int i;
int ret = 0;
int remote_branch = 0;
-+ int force, quiet;
++ 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;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
branch_name_pos = strcspn(fmt, "%");
+ force = flags & DELETE_BRANCH_FORCE;
-+ quiet = flags & DELETE_BRANCH_QUIET;
+
if (!force)
head_rev = lookup_commit_reference(the_repository, &head_oid);
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
+ if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
- force)) {
-- ret = 1;
+ flags)) {
-+ if (!(flags & DELETE_BRANCH_WARN_ONLY))
-+ ret = 1;
+ ret = 1;
goto next;
}
-: ---------- > 3: e101dd2886 branch: let delete_branches skip unmerged branches on bulk refusal
3: 259113e304 ! 4: 6c3534901a branch: prepare delete_branches for a bulk caller
@@ Commit message
branch: prepare delete_branches for a bulk caller
Teach delete_branches() two new modes for the upcoming
- --prune-merged: one that asks only whether a branch is merged into
+ --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.
@@ builtin/branch.c: static int branch_merged(int kind, const char *name,
@@ builtin/branch.c: enum delete_branch_flags {
DELETE_BRANCH_FORCE = (1 << 0),
DELETE_BRANCH_QUIET = (1 << 1),
- DELETE_BRANCH_WARN_ONLY = (1 << 2),
+ 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,
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
- int i;
- int ret = 0;
- int remote_branch = 0;
-- int force, quiet;
-+ int force, quiet, dry_run, no_head_fallback;
+ bool force;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
+ bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
++ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
++ 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;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
force = flags & DELETE_BRANCH_FORCE;
- quiet = flags & DELETE_BRANCH_QUIET;
-+ dry_run = flags & DELETE_BRANCH_DRY_RUN;
-+ no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
- if (!force)
+ if (!force && !no_head_fallback)
4: 9924373da0 ! 5: 5899013b8f branch: add --prune-merged <branch>
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: add --prune-merged <branch>
+ branch: add --delete-merged <branch>
- git branch --prune-merged <branch>...
+ git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
- upstream: the work has already landed on the upstream they track,
+ upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
- Reachability is read from local refs; nothing is fetched. Run
- "git fetch" first if you want fresh upstream refs.
+ Three kinds of branches are not deleted:
- Three kinds of branches are spared:
-
- * any branch checked out in any worktree;
- * any branch whose upstream no longer resolves locally, since a
- missing upstream is not by itself a sign of integration;
+ * any branch checked out in any worktree
+ * any branch whose upstream remote-tracking branch no longer
+ exists, since a missing upstream is not by itself a sign of
+ integration
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
- after a pull it just looks "fully merged", so it is left
- alone. Only branches that push somewhere other than their
- upstream, typically topics in a fork workflow, are candidates.
+ 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.
- Branches that are not yet merged into their upstream are reported
- as a short warning and skipped, so one unmerged topic does not
- abort the whole sweep.
+ A branch whose work is not yet merged into its upstream is silently
+ skipped, so one unmerged topic does not abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-+git branch --prune-merged <branch>...
++git branch --delete-merged <branch>...
DESCRIPTION
-----------
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
-+`--prune-merged <branch>...`::
++`--delete-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
-+ --prune-merged origin/main 'feature*'`.
++ --delete-merged origin/main 'feature*'`.
++
-+Reachability is checked against whatever the upstream refs say
-+locally; nothing is fetched. Run `git fetch` first if you want
-+the upstream refs refreshed.
++A branch is not deleted when:
++
-+A branch is left alone if any of the following holds:
-+its upstream no longer resolves locally; it is checked out in any
-+worktree; or its push destination (`<branch>@{push}`) equals its
-+upstream (`<branch>@{upstream}`), so it cannot be distinguished
-+from a freshly pulled trunk that just looks "fully merged".
++--
++* its upstream remote-tracking branch no longer exists,
++* it is checked out in any worktree, or
++* its push destination (`<branch>@{push}`) equals its upstream
++ (`<branch>@{upstream}`), so it cannot be distinguished from a
++ branch that just looks "fully merged" right after a pull.
++--
++
-+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.
++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.
+
`-v`::
`-vv`::
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
-+ N_("git branch [<options>] --prune-merged <branch>..."),
++ N_("git branch [<options>] --delete-merged <branch>..."),
NULL
};
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
return 0;
}
-+static int prune_merged_branches(int argc, const char **argv,
++static int delete_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
-+ struct ref_array candidates;
++ struct ref_array candidates = { 0 };
+ struct strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
-+ die(_("--prune-merged requires at least one <branch>"));
++ die(_("--delete-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
-+ memset(&candidates, 0, sizeof(candidates));
+ filter_refs(&candidates, &filter, filter.kind);
+
+ for (i = 0; i < candidates.nr; i++) {
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ const char *upstream, *push;
+
+ if (!skip_prefix(full_name, "refs/heads/", &short_name))
-+ continue;
++ BUG("filter returned non-branch ref '%s'", full_name);
+ if (branch_checked_out(full_name))
+ continue;
+
+ branch = branch_get(short_name);
-+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
++ upstream = branch_get_upstream(branch, NULL);
+ if (!upstream || !refs_ref_exists(refs, upstream))
+ continue;
-+ push = branch ? branch_get_push(branch, NULL) : NULL;
++ push = branch_get_push(branch, NULL);
+ if (!push || !strcmp(push, upstream))
+ continue;
+
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ FILTER_REFS_BRANCHES,
-+ DELETE_BRANCH_WARN_ONLY |
++ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+
@@ builtin/branch.c: 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 prune_merged = 0;
++ int delete_merged = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: 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, "prune-merged", &prune_merged,
-+ N_("delete local branches whose upstream matches <branch> and is merged")),
++ OPT_BOOL(0, "delete-merged", &delete_merged,
++ N_("delete local branches whose upstream matches <branch> 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")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (!delete && !rename && !copy && !edit_description && !new_upstream &&
- !show_current && !unset_upstream && argc == 0)
-+ !show_current && !unset_upstream && !prune_merged &&
++ !show_current && !unset_upstream && !delete_merged &&
+ argc == 0)
list = 1;
@@ builtin/branch.c: int cmd_branch(int argc,
noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
!!show_current + !!list + !!edit_description +
- !!unset_upstream;
-+ !!unset_upstream + !!prune_merged;
++ !!unset_upstream + !!delete_merged;
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
@@ builtin/branch.c: int cmd_branch(int argc,
(delete > 1 ? DELETE_BRANCH_FORCE : 0) |
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
-+ } else if (prune_merged) {
-+ ret = prune_merged_branches(argc, argv, quiet);
++ } else if (delete_merged) {
++ ret = delete_merged_branches(argc, argv, quiet);
+ goto out;
} else if (show_current) {
print_current_branch_name();
ret = 0;
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
- test_grep "requires a value" err
+@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ test_cmp expect actual
'
-+test_expect_success '--prune-merged: setup' '
++test_expect_success '--delete-merged: setup' '
+ test_create_repo pm-upstream &&
+ test_commit -C pm-upstream base &&
+ git -C pm-upstream checkout -b next &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ test_create_repo pm-fork
+'
+
-+test_expect_success '--prune-merged deletes branches integrated into upstream' '
++test_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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/*" &&
++ git -C pm-merged branch --delete-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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 &&
++ git -C pm-literal branch --delete-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
-+test_expect_success '--prune-merged unions multiple <branch> arguments' '
++test_expect_success '--delete-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
-+ git -C pm-union branch --prune-merged origin/next origin/main &&
++ git -C pm-union branch --delete-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
-+test_expect_success '--prune-merged accepts a local upstream' '
++test_expect_success '--delete-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 checkout -b mainline &&
+ git -C pm-local branch one one-commit &&
-+ git -C pm-local branch --set-upstream-to=trunk one &&
++ git -C pm-local branch --set-upstream-to=mainline one &&
+ git -C pm-local merge --ff-only one-commit &&
+
-+ git -C pm-local branch --prune-merged trunk &&
++ git -C pm-local branch --delete-merged mainline &&
+
+ 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_expect_success '--delete-merged silently skips 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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 branch --delete-merged "origin/*" 2>err &&
++ test_grep ! "not fully merged" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
-+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
++test_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 &&
++ git -C pm-nohead branch --delete-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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 branch --delete-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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 branch --delete-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_expect_success '--delete-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 branch --delete-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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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 branch --delete-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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ 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/*" &&
++ git -C pm-push-diff branch --delete-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
-+test_expect_success '--prune-merged requires at least one <branch>' '
-+ test_must_fail git -C forked branch --prune-merged 2>err &&
++test_expect_success '--delete-merged requires at least one <branch>' '
++ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
-+test_expect_success '--prune-merged takes positional <branch> arguments' '
++test_expect_success '--delete-merged takes positional <branch> arguments' '
+ test_when_finished "rm -rf pm-positional" &&
+ git clone pm-upstream pm-positional &&
+ git -C pm-positional remote add fork ../pm-fork &&
@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
+ git -C pm-positional branch --set-upstream-to=origin/main two &&
+ git -C pm-positional checkout --detach &&
+
-+ git -C pm-positional branch --prune-merged origin/next origin/main &&
++ git -C pm-positional branch --delete-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
5: d691d5051b ! 6: 72aaca0666 branch: add branch.<name>.pruneMerged opt-out
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: add branch.<name>.pruneMerged opt-out
+ branch: add branch.<name>.deleteMerged opt-out
- Setting branch.<name>.pruneMerged=false exempts that branch from
- "git branch --prune-merged", which is useful for a topic you want
+ Setting branch.<name>.deleteMerged=false exempts that branch from
+ "git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
@@ Documentation/config/branch.adoc: for details).
automatically added to the `format-patch` cover letter or
`request-pull` summary.
+
-+`branch.<name>.pruneMerged`::
++`branch.<name>.deleteMerged`::
+ If set to `false`, branch _<name>_ is exempt from
-+ `git branch --prune-merged`. Useful for a topic branch you
++ `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.
## Documentation/git-branch.adoc ##
-@@ Documentation/git-branch.adoc: the upstream refs refreshed.
+@@ Documentation/git-branch.adoc: A branch is not deleted when:
+
- A branch is left alone if any of the following holds:
- its upstream no longer resolves locally; it is checked out in any
--worktree; or its push destination (`<branch>@{push}`) equals its
-+worktree; its push destination (`<branch>@{push}`) equals its
- upstream (`<branch>@{upstream}`), so it cannot be distinguished
--from a freshly pulled trunk that just looks "fully merged".
-+from a freshly pulled trunk that just looks "fully merged"; or
-+`branch.<name>.pruneMerged` is set to `false`.
+ --
+ * 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 (`<branch>@{push}`) equals its upstream
+ (`<branch>@{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.<name>.deleteMerged` 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
+ A branch whose work has not yet been merged into its upstream is
## builtin/branch.c ##
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
- continue;
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+ BUG("filter returned non-branch ref '%s'", full_name);
+@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
-+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
++ 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.pruneMerged is false)\n"),
++ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
+ strbuf_release(&key);
+ continue;
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '
test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
-+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
++test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
+ test_when_finished "rm -rf pm-optout" &&
+ git clone pm-upstream pm-optout &&
+ git -C pm-optout remote add fork ../pm-fork &&
@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch>
+ 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 &&
++ test_config -C pm-optout branch.one.deleteMerged false &&
+
-+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
++ git -C pm-optout branch --delete-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_expect_success 'branch -d still deletes a deleteMerged=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 &&
++ test_config -C pm-optout-d branch.one.deleteMerged false &&
+
+ git -C pm-optout-d branch -d one &&
+ test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
6: ede8c61729 ! 7: 7b2b01b988 branch: add --dry-run for --prune-merged
@@ Metadata
Author: Harald Nordgren <haraldnordgren@gmail.com>
## Commit message ##
- branch: add --dry-run for --prune-merged
+ branch: add --dry-run for --delete-merged
- With --dry-run, --prune-merged prints the local branches it would
+ With --dry-run, --delete-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
- --dry-run is only meaningful together with --prune-merged and is
+ --dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
--git branch --prune-merged <branch>...
-+git branch [--dry-run] --prune-merged <branch>...
+-git branch --delete-merged <branch>...
++git branch [--dry-run] --delete-merged <branch>...
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: 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.
+@@ Documentation/git-branch.adoc: 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.
+`--dry-run`::
-+ With `--prune-merged`, print which branches would be
++ 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.
@@ builtin/branch.c
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
- static int prune_merged_branches(int argc, const char **argv,
+ static int delete_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct ref_filter filter = REF_FILTER_INIT;
-@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
+@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES,
- DELETE_BRANCH_WARN_ONLY |
+ DELETE_BRANCH_SKIP_UNMERGED |
DELETE_BRANCH_NO_HEAD_FALLBACK |
- (quiet ? DELETE_BRANCH_QUIET : 0));
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
- int prune_merged = 0;
+ int delete_merged = 0;
+ int dry_run = 0;
const char *new_upstream = NULL;
int noncreate_actions = 0;
/* possible options */
@@ builtin/branch.c: int cmd_branch(int argc,
N_("edit the description for the branch")),
- OPT_BOOL(0, "prune-merged", &prune_merged,
- N_("delete local branches whose upstream matches <branch> and is merged")),
+ OPT_BOOL(0, "delete-merged", &delete_merged,
+ N_("delete local branches whose upstream matches <branch> and are merged")),
+ OPT_BOOL(0, "dry-run", &dry_run,
-+ N_("with --prune-merged, only print which branches would be deleted")),
++ 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")),
@@ builtin/branch.c: int cmd_branch(int argc,
if (noncreate_actions > 1)
usage_with_options(builtin_branch_usage, options);
-+ if (dry_run && !prune_merged)
-+ die(_("--dry-run requires --prune-merged"));
++ if (dry_run && !delete_merged)
++ die(_("--dry-run requires --delete-merged"));
+
if (recurse_submodules_explicit) {
if (!submodule_propagate_branches)
@@ builtin/branch.c: int cmd_branch(int argc,
@@ builtin/branch.c: int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
- } else if (prune_merged) {
-- ret = prune_merged_branches(argc, argv, quiet);
-+ ret = prune_merged_branches(argc, argv, quiet, dry_run);
+ } else if (delete_merged) {
+- ret = delete_merged_branches(argc, argv, quiet);
++ ret = delete_merged_branches(argc, argv, quiet, dry_run);
goto out;
} else if (show_current) {
print_current_branch_name();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ 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 &&
++ git -C pm-dry branch --dry-run --delete-merged "origin/*" >actual &&
+ test_grep "Would delete branch one " actual &&
+ test_grep "Would delete branch two " actual &&
+
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ 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_expect_success '--delete-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 &&
@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
+ 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 &&
++ git -C pm-dry-mixed branch --dry-run --delete-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_expect_success '--dry-run without --delete-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
-+ test_grep "requires --prune-merged" err
++ test_grep "requires --delete-merged" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v15 1/7] branch: add --forked filter for --list mode
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
` (7 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 12 +++-
builtin/branch.c | 18 +++++-
ref-filter.c | 70 +++++++++++++++++++++
ref-filter.h | 10 +++
t/t3200-branch.sh | 113 ++++++++++++++++++++++++++++++++++
5 files changed, 220 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[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -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 _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`), a remote name like `origin` for the branch its
+ `origin/HEAD` points at, or a shell-style glob (e.g.
+ `'origin/*'`). The option can be repeated to widen the
+ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -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 <branch> (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 <branch> 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..fac2ad55ac 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,117 @@ 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 -f other ../forked-other &&
+ git -C forked remote set-head origin one &&
+ 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 --track local-onbase local-base &&
+
+ git -C forked checkout local-one &&
+ test_commit -C forked --no-tag local-one-work local-one.t &&
+ git -C forked checkout local-foreign &&
+ test_commit -C forked --no-tag local-foreign-work local-foreign.t &&
+ git -C forked checkout --detach
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-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 <remote> uses the branch <remote>/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 <pattern> 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 2/7] branch: convert delete_branches() to a flags argument
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
` (6 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() and check_branch_commit() take a pair of int
booleans (force and quiet) that the next commits would grow further.
Replace them with a single "unsigned int flags" argument and an
enum, splitting the bits back into named bool locals so the body
keeps reading the same named values.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a skip-unmerged mode to delete_branches() and check_branch_commit()
so a bulk caller can silently skip branches that are not fully merged
and carry on, rather than erroring with the "use 'git branch -D'"
advice that the plain "git branch -d" path emits. Existing callers are
unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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;
}
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 4/7] branch: prepare delete_branches for a bulk caller
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-15 16:47 ` [PATCH v15 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--delete-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 4c569d056a..1d3f28e4cb 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,8 @@ 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),
+ DELETE_BRANCH_DRY_RUN = (1 << 4),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -241,6 +246,8 @@ 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 dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ 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 +275,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)) {
@@ -339,13 +346,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 5/7] branch: add --delete-merged <branch>
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-15 16:47 ` [PATCH v15 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
Three kinds of branches are not deleted:
* any branch checked out in any worktree
* any branch whose upstream remote-tracking branch no longer
exists, since a missing upstream is not by itself a sign of
integration
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is 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.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 24 ++++
builtin/branch.c | 66 ++++++++++-
t/t3200-branch.sh | 200 ++++++++++++++++++++++++++++++++++
3 files changed, 288 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index b0d66a6deb..f82cfa36d0 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --delete-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--delete-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --delete-merged origin/main 'feature*'`.
++
+A branch is not deleted when:
++
+--
+* its upstream remote-tracking branch no longer exists,
+* it is checked out in any worktree, or
+* its push destination (`<branch>@{push}`) equals its upstream
+ (`<branch>@{upstream}`), so it cannot be distinguished from a
+ branch that just looks "fully merged" right after a pull.
+--
++
+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.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1d3f28e4cb..f01e03cc26 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --delete-merged <branch>..."),
NULL
};
@@ -714,6 +715,60 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+static int delete_merged_branches(int argc, const char **argv,
+ int quiet)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates = { 0 };
+ struct strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--delete-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ 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;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ (quiet ? DELETE_BRANCH_QUIET : 0));
+
+ strvec_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)
@@ -755,6 +810,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 */
@@ -808,6 +864,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 <branch> 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")),
@@ -855,7 +913,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 ||
@@ -865,7 +924,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);
@@ -907,6 +966,9 @@ 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);
+ 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 fac2ad55ac..b74e119d3b 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1830,4 +1830,204 @@ test_expect_success '--forked narrows a <pattern> argument' '
test_cmp expect actual
'
+test_expect_success '--delete-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 '--delete-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 --delete-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 '--delete-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 --delete-merged origin/next &&
+
+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--delete-merged unions multiple <branch> arguments' '
+ test_when_finished "rm -rf pm-union" &&
+ git clone pm-upstream pm-union &&
+ git -C pm-union remote add fork ../pm-fork &&
+ test_config -C pm-union remote.pushDefault fork &&
+ test_config -C pm-union push.default current &&
+ git -C pm-union branch one one-commit &&
+ git -C pm-union branch --set-upstream-to=origin/next one &&
+ git -C pm-union branch two base &&
+ git -C pm-union branch --set-upstream-to=origin/main two &&
+ git -C pm-union checkout --detach &&
+
+ git -C pm-union branch --delete-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--delete-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 mainline &&
+ git -C pm-local branch one one-commit &&
+ git -C pm-local branch --set-upstream-to=mainline one &&
+ git -C pm-local merge --ff-only one-commit &&
+
+ git -C pm-local branch --delete-merged mainline &&
+
+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--delete-merged silently skips 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 --delete-merged "origin/*" 2>err &&
+ test_grep ! "not fully merged" err &&
+ git -C pm-unmerged rev-parse --verify refs/heads/wip
+'
+
+test_expect_success '--delete-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 --delete-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 '--delete-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 --delete-merged "origin/*" &&
+
+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--delete-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 --delete-merged "origin/*" &&
+
+ git -C pm-head rev-parse --verify refs/heads/one
+'
+
+test_expect_success '--delete-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 --delete-merged "origin/*" &&
+
+ git -C pm-push-eq rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--delete-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 --delete-merged "origin/*" &&
+
+ git -C pm-push-branch rev-parse --verify refs/heads/main
+'
+
+test_expect_success '--delete-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 --delete-merged "origin/*" &&
+
+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
+'
+
+test_expect_success '--delete-merged requires at least one <branch>' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
+test_expect_success '--delete-merged takes positional <branch> arguments' '
+ test_when_finished "rm -rf pm-positional" &&
+ git clone pm-upstream pm-positional &&
+ git -C pm-positional remote add fork ../pm-fork &&
+ test_config -C pm-positional remote.pushDefault fork &&
+ test_config -C pm-positional push.default current &&
+ git -C pm-positional branch one one-commit &&
+ git -C pm-positional branch --set-upstream-to=origin/next one &&
+ git -C pm-positional branch two base &&
+ git -C pm-positional branch --set-upstream-to=origin/main two &&
+ git -C pm-positional checkout --detach &&
+
+ git -C pm-positional branch --delete-merged origin/next origin/main &&
+
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 6/7] branch: add branch.<name>.deleteMerged opt-out
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-15 16:47 ` [PATCH v15 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-15 16:47 ` [PATCH v15 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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..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.<name>.deleteMerged`::
+ If set to `false`, branch _<name>_ 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 f82cfa36d0..91700f2e8a 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 (`<branch>@{push}`) equals its upstream
(`<branch>@{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.<name>.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 f01e03cc26..0e1e7c2e6f 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -739,6 +739,8 @@ static int delete_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
+ struct strbuf key = STRBUF_INIT;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
@@ -753,6 +755,18 @@ static int delete_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
continue;
+ 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);
+ 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 b74e119d3b..5ac3c2bb5d 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2030,4 +2030,34 @@ test_expect_success '--delete-merged takes positional <branch> arguments' '
test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
'
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=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.deleteMerged false &&
+
+ git -C pm-optout branch --delete-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 deleteMerged=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.deleteMerged 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v15 7/7] branch: add --dry-run for --delete-merged
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-15 16:47 ` [PATCH v15 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-15 16:47 ` Harald Nordgren via GitGitGadget
2026-06-17 10:01 ` [PATCH v15 0/7] branch: delete-merged Phillip Wood
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
8 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-15 16:47 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --delete-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 ++++++-
builtin/branch.c | 13 ++++++++---
t/t3200-branch.sh | 44 +++++++++++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 91700f2e8a..09063d74f2 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --delete-merged <branch>...
+git branch [--dry-run] --delete-merged <branch>...
DESCRIPTION
-----------
@@ -226,6 +226,12 @@ 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.
+`--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 0e1e7c2e6f..d18a830249 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -716,7 +716,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
}
static int delete_merged_branches(int argc, const char **argv,
- int quiet)
+ int quiet, int dry_run)
{
struct ref_store *refs = get_main_ref_store(the_repository);
struct ref_filter filter = REF_FILTER_INIT;
@@ -775,7 +775,8 @@ static int delete_merged_branches(int argc, const char **argv,
FILTER_REFS_BRANCHES,
DELETE_BRANCH_SKIP_UNMERGED |
DELETE_BRANCH_NO_HEAD_FALLBACK |
- (quiet ? DELETE_BRANCH_QUIET : 0));
+ (quiet ? DELETE_BRANCH_QUIET : 0) |
+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
strvec_clear(&deletable);
ref_array_clear(&candidates);
@@ -825,6 +826,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 */
@@ -880,6 +882,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 <branch> 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")),
@@ -942,6 +946,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"));
@@ -981,7 +988,7 @@ int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (delete_merged) {
- ret = delete_merged_branches(argc, argv, quiet);
+ ret = delete_merged_branches(argc, argv, 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 5ac3c2bb5d..1cb32497b8 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2060,4 +2060,48 @@ test_expect_success 'branch -d still deletes a deleteMerged=false branch' '
test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
'
+test_expect_success '--delete-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 --delete-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 '--delete-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 --delete-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 --delete-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
+ test_grep "requires --delete-merged" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (6 preceding siblings ...)
2026-06-15 16:47 ` [PATCH v15 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
@ 2026-06-17 10:01 ` Phillip Wood
2026-06-17 11:17 ` Harald Nordgren
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
8 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-17 10:01 UTC (permalink / raw)
To: Harald Nordgren via GitGitGadget, git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
Hi Harald
Our SubmittingPatches documentation recommends waiting for the
discussion to settle before sending a new version. When you know someone
is going send more comments on a series it is a good idea to wait for
them before sending a new version to avoid too much churn on the list
which makes it hard for people to keep up. I'm not going to read this
version in detail because I know another version will be needed but I
did spot a couple of things in the summary below.
On 15/06/2026 17:47, Harald Nordgren via GitGitGadget wrote:
> * Renamed --prune-merged to --delete-merged throughout. Not necessarily
> final, but something to advance the discussion.
> * --delete-merged now silently skips not-yet-merged branches instead of
> warning.
Good
> * --forked now accepts a bare remote name (e.g. origin) for the branch
> origin/HEAD points at using DWIM.
The range-diff below does not show any changes to the implementation,
only the Documentation and tests
> * Initialized the delete_branches() flag locals where declared. Only force
> stays deferred.
Not changing force sounds like a bad idea. The whole point of unpacking
the flags at the start of the function is to avoid accidental
regressions. Unpacking the flags into separate variables means the rest
of the function does not need to know that the function arguments have
changed.
Thanks
Phillip
> * delete_branches()/check_branch_commit() doc and code cleanups: redundant
> branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
> unreachable non-branch ref, and reworked --delete-merged doc wording.
> * Broadened the --forked tests (local commits for realism, remote add -f,
> --forked <pattern> <branch> coverage), renamed the misleading trunk
> fixture, and replaced the misnamed detached branch with git checkout
> --detach.
>
> Harald Nordgren (7):
> branch: add --forked filter for --list mode
> branch: convert delete_branches() to a flags argument
> branch: let delete_branches skip unmerged branches on bulk refusal
> branch: prepare delete_branches for a bulk caller
> branch: add --delete-merged <branch>
> branch: add branch.<name>.deleteMerged opt-out
> branch: add --dry-run for --delete-merged
>
> Documentation/config/branch.adoc | 7 +
> Documentation/git-branch.adoc | 43 +++-
> builtin/branch.c | 184 ++++++++++++---
> ref-filter.c | 70 ++++++
> ref-filter.h | 10 +
> t/t3200-branch.sh | 387 +++++++++++++++++++++++++++++++
> 6 files changed, 673 insertions(+), 28 deletions(-)
>
>
> base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v15
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v15
> Pull-Request: https://github.com/git/git/pull/2285
>
> Range-diff vs v14:
>
> 1: 7383872f4b ! 1: da741b5ea7 branch: add --forked filter for --list mode
> @@ Commit message
>
> Add a --forked option to "git branch" list mode that lists only
> branches whose configured upstream matches <branch>. The argument
> - can be a ref (e.g. "origin/main", "master") or a shell glob
> + 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:
> @@ Commit message
> lists branches forked from origin that are already merged into
> origin/main, and --no-merged inverts the question.
>
> - This is the building block for --prune-merged, which deletes the
> + This is the building block for --delete-merged, which deletes the
> listed branches once they have landed on their upstream.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> @@ Documentation/git-branch.adoc: superproject's "origin/main", but tracks the subm
> +`--forked <branch>`::
> + Only list branches whose configured upstream matches
> + _<branch>_. The argument can be a ref (e.g. `origin/main`,
> -+ `master`) or a shell-style glob (e.g. `'origin/*'`). The
> -+ option can be repeated to widen the filter. Implies `--list`.
> ++ `master`), a remote name like `origin` for the branch its
> ++ `origin/HEAD` points at, or a shell-style glob (e.g.
> ++ `'origin/*'`). The option can be repeated to widen the
> ++ filter. Implies `--list`.
> +
> `--points-at <object>`::
> Only list branches of _<object>_.
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + 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 remote add -f other ../forked-other &&
> ++ git -C forked remote set-head origin one &&
> + 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
> ++ git -C forked branch --track local-onbase local-base &&
> ++
> ++ git -C forked checkout local-one &&
> ++ test_commit -C forked --no-tag local-one-work local-one.t &&
> ++ git -C forked checkout local-foreign &&
> ++ test_commit -C forked --no-tag local-foreign-work local-foreign.t &&
> ++ git -C forked checkout --detach
> +'
> +
> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> +
> +test_expect_success '--forked <local-branch> matches branches with local upstream' '
> + git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
> -+ echo local-trunk >expect &&
> ++ echo local-onbase >expect &&
> + test_cmp expect actual
> +'
> +
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> -+ local-trunk
> ++ local-onbase
> + EOF
> + test_cmp expect actual
> +'
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> +'
> +
> +test_expect_success '--forked composes with --no-merged' '
> -+ test_when_finished "git -C forked checkout detached" &&
> ++ 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 \
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + test_must_fail git -C forked branch --forked 2>err &&
> + test_grep "requires a value" err
> +'
> ++
> ++test_expect_success '--forked <remote> uses the branch <remote>/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 <pattern> 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
> 2: 7ef9502e01 ! 2: 91c35f10cc branch: let delete_branches warn instead of error on bulk refusal
> @@ Metadata
> Author: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Commit message ##
> - branch: let delete_branches warn instead of error on bulk refusal
> + branch: convert delete_branches() to a flags argument
>
> - Add a warn-only mode to delete_branches() and check_branch_commit()
> - so a bulk caller can report branches that are not fully merged as a
> - short warning and carry on, rather than erroring with the longer
> - "use 'git branch -D'" advice that the plain "git branch -d" path
> - emits. Existing callers are unaffected.
> + delete_branches() and check_branch_commit() take a pair of int
> + booleans (force and quiet) that the next commits would grow further.
> + Replace them with a single "unsigned int flags" argument and an
> + enum, splitting the bits back into named bool locals so the body
> + keeps reading the same named values.
> +
> + No change in behavior.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> @@ builtin/branch.c: 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_WARN_ONLY = (1 << 2),
> +};
> +
> static int check_branch_commit(const char *branchname, const char *refname,
> @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
> - int kinds, int force)
> + int kinds, unsigned int flags)
> {
> -+ int force = flags & DELETE_BRANCH_FORCE;
> ++ 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);
> - 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 (flags & DELETE_BRANCH_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;
> @@ builtin/branch.c: static void delete_branch_config(const char *branchname)
> strbuf_release(&buf);
> }
> @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
> int i;
> int ret = 0;
> int remote_branch = 0;
> -+ int force, quiet;
> ++ 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;
> @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
> branch_name_pos = strcspn(fmt, "%");
>
> + force = flags & DELETE_BRANCH_FORCE;
> -+ quiet = flags & DELETE_BRANCH_QUIET;
> +
> if (!force)
> head_rev = lookup_commit_reference(the_repository, &head_oid);
> @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
> + if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
> check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
> - force)) {
> -- ret = 1;
> + flags)) {
> -+ if (!(flags & DELETE_BRANCH_WARN_ONLY))
> -+ ret = 1;
> + ret = 1;
> goto next;
> }
>
> -: ---------- > 3: e101dd2886 branch: let delete_branches skip unmerged branches on bulk refusal
> 3: 259113e304 ! 4: 6c3534901a branch: prepare delete_branches for a bulk caller
> @@ Commit message
> branch: prepare delete_branches for a bulk caller
>
> Teach delete_branches() two new modes for the upcoming
> - --prune-merged: one that asks only whether a branch is merged into
> + --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.
> @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
> @@ builtin/branch.c: enum delete_branch_flags {
> DELETE_BRANCH_FORCE = (1 << 0),
> DELETE_BRANCH_QUIET = (1 << 1),
> - DELETE_BRANCH_WARN_ONLY = (1 << 2),
> + 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,
> @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
> - int i;
> - int ret = 0;
> - int remote_branch = 0;
> -- int force, quiet;
> -+ int force, quiet, dry_run, no_head_fallback;
> + bool force;
> + bool quiet = flags & DELETE_BRANCH_QUIET;
> + bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
> ++ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
> ++ 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;
> @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
>
> force = flags & DELETE_BRANCH_FORCE;
> - quiet = flags & DELETE_BRANCH_QUIET;
> -+ dry_run = flags & DELETE_BRANCH_DRY_RUN;
> -+ no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
>
> - if (!force)
> + if (!force && !no_head_fallback)
> 4: 9924373da0 ! 5: 5899013b8f branch: add --prune-merged <branch>
> @@ Metadata
> Author: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Commit message ##
> - branch: add --prune-merged <branch>
> + branch: add --delete-merged <branch>
>
> - git branch --prune-merged <branch>...
> + git branch --delete-merged <branch>...
>
> deletes the local branches that "--forked <branch>" would list,
> keeping only those whose tip is reachable from their configured
> - upstream: the work has already landed on the upstream they track,
> + upstream. The work has already landed on the upstream they track,
> so the local copy is no longer needed.
>
> - Reachability is read from local refs; nothing is fetched. Run
> - "git fetch" first if you want fresh upstream refs.
> + Three kinds of branches are not deleted:
>
> - Three kinds of branches are spared:
> -
> - * any branch checked out in any worktree;
> - * any branch whose upstream no longer resolves locally, since a
> - missing upstream is not by itself a sign of integration;
> + * any branch checked out in any worktree
> + * any branch whose upstream remote-tracking branch no longer
> + exists, since a missing upstream is not by itself a sign of
> + integration
> * any branch whose push destination equals its upstream
> (<branch>@{push} is the same as <branch>@{upstream}), such as
> a local "main" that tracks and pushes to "origin/main". Right
> - after a pull it just looks "fully merged", so it is left
> - alone. Only branches that push somewhere other than their
> - upstream, typically topics in a fork workflow, are candidates.
> + 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.
>
> - Branches that are not yet merged into their upstream are reported
> - as a short warning and skipped, so one unmerged topic does not
> - abort the whole sweep.
> + A branch whose work is not yet merged into its upstream is silently
> + skipped, so one unmerged topic does not abort the whole sweep.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>
> @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
> git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> -+git branch --prune-merged <branch>...
> ++git branch --delete-merged <branch>...
>
> DESCRIPTION
> -----------
> @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
>
> -+`--prune-merged <branch>...`::
> ++`--delete-merged <branch>...`::
> + Delete the local branches that `--forked` would list for the
> + given _<branch>_ arguments, but only those whose tip is
> + reachable from their configured upstream. In other words, the
> + work on the branch has already landed on the upstream it
> + tracks, so the local copy is no longer needed. Several
> + _<branch>_ patterns may be given, e.g. `git branch
> -+ --prune-merged origin/main 'feature*'`.
> ++ --delete-merged origin/main 'feature*'`.
> ++
> -+Reachability is checked against whatever the upstream refs say
> -+locally; nothing is fetched. Run `git fetch` first if you want
> -+the upstream refs refreshed.
> ++A branch is not deleted when:
> ++
> -+A branch is left alone if any of the following holds:
> -+its upstream no longer resolves locally; it is checked out in any
> -+worktree; or its push destination (`<branch>@{push}`) equals its
> -+upstream (`<branch>@{upstream}`), so it cannot be distinguished
> -+from a freshly pulled trunk that just looks "fully merged".
> ++--
> ++* its upstream remote-tracking branch no longer exists,
> ++* it is checked out in any worktree, or
> ++* its push destination (`<branch>@{push}`) equals its upstream
> ++ (`<branch>@{upstream}`), so it cannot be distinguished from a
> ++ branch that just looks "fully merged" right after a pull.
> ++--
> ++
> -+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.
> ++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.
> +
> `-v`::
> `-vv`::
> @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
> N_("git branch [<options>] [-r | -a] [--points-at]"),
> N_("git branch [<options>] [-r | -a] [--format]"),
> -+ N_("git branch [<options>] --prune-merged <branch>..."),
> ++ N_("git branch [<options>] --delete-merged <branch>..."),
> NULL
> };
>
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> return 0;
> }
>
> -+static int prune_merged_branches(int argc, const char **argv,
> ++static int delete_merged_branches(int argc, const char **argv,
> + int quiet)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct ref_filter filter = REF_FILTER_INIT;
> -+ struct ref_array candidates;
> ++ struct ref_array candidates = { 0 };
> + struct strvec deletable = STRVEC_INIT;
> + int i, ret = 0;
> +
> + if (!argc)
> -+ die(_("--prune-merged requires at least one <branch>"));
> ++ die(_("--delete-merged requires at least one <branch>"));
> +
> + for (i = 0; i < argc; i++)
> + if (ref_filter_forked_add(&filter, argv[i]) < 0)
> + die(_("'%s' is not a valid branch or pattern"), argv[i]);
> +
> + filter.kind = FILTER_REFS_BRANCHES;
> -+ memset(&candidates, 0, sizeof(candidates));
> + filter_refs(&candidates, &filter, filter.kind);
> +
> + for (i = 0; i < candidates.nr; i++) {
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> + const char *upstream, *push;
> +
> + if (!skip_prefix(full_name, "refs/heads/", &short_name))
> -+ continue;
> ++ BUG("filter returned non-branch ref '%s'", full_name);
> + if (branch_checked_out(full_name))
> + continue;
> +
> + branch = branch_get(short_name);
> -+ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> ++ upstream = branch_get_upstream(branch, NULL);
> + if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> -+ push = branch ? branch_get_push(branch, NULL) : NULL;
> ++ push = branch_get_push(branch, NULL);
> + if (!push || !strcmp(push, upstream))
> + continue;
> +
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + FILTER_REFS_BRANCHES,
> -+ DELETE_BRANCH_WARN_ONLY |
> ++ DELETE_BRANCH_SKIP_UNMERGED |
> + DELETE_BRANCH_NO_HEAD_FALLBACK |
> + (quiet ? DELETE_BRANCH_QUIET : 0));
> +
> @@ builtin/branch.c: 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 prune_merged = 0;
> ++ int delete_merged = 0;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ builtin/branch.c: 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, "prune-merged", &prune_merged,
> -+ N_("delete local branches whose upstream matches <branch> and is merged")),
> ++ OPT_BOOL(0, "delete-merged", &delete_merged,
> ++ N_("delete local branches whose upstream matches <branch> 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")),
> @@ builtin/branch.c: int cmd_branch(int argc,
>
> if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && argc == 0)
> -+ !show_current && !unset_upstream && !prune_merged &&
> ++ !show_current && !unset_upstream && !delete_merged &&
> + argc == 0)
> list = 1;
>
> @@ builtin/branch.c: int cmd_branch(int argc,
> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream;
> -+ !!unset_upstream + !!prune_merged;
> ++ !!unset_upstream + !!delete_merged;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
>
> @@ builtin/branch.c: int cmd_branch(int argc,
> (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
> (quiet ? DELETE_BRANCH_QUIET : 0));
> goto out;
> -+ } else if (prune_merged) {
> -+ ret = prune_merged_branches(argc, argv, quiet);
> ++ } else if (delete_merged) {
> ++ ret = delete_merged_branches(argc, argv, quiet);
> + goto out;
> } else if (show_current) {
> print_current_branch_name();
> ret = 0;
>
> ## t/t3200-branch.sh ##
> -@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> - test_grep "requires a value" err
> +@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
> + test_cmp expect actual
> '
>
> -+test_expect_success '--prune-merged: setup' '
> ++test_expect_success '--delete-merged: setup' '
> + test_create_repo pm-upstream &&
> + test_commit -C pm-upstream base &&
> + git -C pm-upstream checkout -b next &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + test_create_repo pm-fork
> +'
> +
> -+test_expect_success '--prune-merged deletes branches integrated into upstream' '
> ++test_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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/*" &&
> ++ git -C pm-merged branch --delete-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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 &&
> ++ git -C pm-literal branch --delete-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> -+test_expect_success '--prune-merged unions multiple <branch> arguments' '
> ++test_expect_success '--delete-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> + git -C pm-union remote add fork ../pm-fork &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> + git -C pm-union checkout --detach &&
> +
> -+ git -C pm-union branch --prune-merged origin/next origin/main &&
> ++ git -C pm-union branch --delete-merged origin/next origin/main &&
> +
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged accepts a local upstream' '
> ++test_expect_success '--delete-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 checkout -b mainline &&
> + git -C pm-local branch one one-commit &&
> -+ git -C pm-local branch --set-upstream-to=trunk one &&
> ++ git -C pm-local branch --set-upstream-to=mainline one &&
> + git -C pm-local merge --ff-only one-commit &&
> +
> -+ git -C pm-local branch --prune-merged trunk &&
> ++ git -C pm-local branch --delete-merged mainline &&
> +
> + 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_expect_success '--delete-merged silently skips 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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 branch --delete-merged "origin/*" 2>err &&
> ++ test_grep ! "not fully merged" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> -+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> ++test_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 &&
> ++ git -C pm-nohead branch --delete-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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 branch --delete-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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 branch --delete-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_expect_success '--delete-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 branch --delete-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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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 branch --delete-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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + 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/*" &&
> ++ git -C pm-push-diff branch --delete-merged "origin/*" &&
> +
> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> -+test_expect_success '--prune-merged requires at least one <branch>' '
> -+ test_must_fail git -C forked branch --prune-merged 2>err &&
> ++test_expect_success '--delete-merged requires at least one <branch>' '
> ++ test_must_fail git -C forked branch --delete-merged 2>err &&
> + test_grep "requires at least one <branch>" err
> +'
> +
> -+test_expect_success '--prune-merged takes positional <branch> arguments' '
> ++test_expect_success '--delete-merged takes positional <branch> arguments' '
> + test_when_finished "rm -rf pm-positional" &&
> + git clone pm-upstream pm-positional &&
> + git -C pm-positional remote add fork ../pm-fork &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
> + git -C pm-positional branch --set-upstream-to=origin/main two &&
> + git -C pm-positional checkout --detach &&
> +
> -+ git -C pm-positional branch --prune-merged origin/next origin/main &&
> ++ git -C pm-positional branch --delete-merged origin/next origin/main &&
> +
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> 5: d691d5051b ! 6: 72aaca0666 branch: add branch.<name>.pruneMerged opt-out
> @@ Metadata
> Author: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Commit message ##
> - branch: add branch.<name>.pruneMerged opt-out
> + branch: add branch.<name>.deleteMerged opt-out
>
> - Setting branch.<name>.pruneMerged=false exempts that branch from
> - "git branch --prune-merged", which is useful for a topic you want
> + Setting branch.<name>.deleteMerged=false exempts that branch from
> + "git branch --delete-merged", which is useful for a topic you want
> to keep developing after an early round of it has been merged
> upstream. Unless --quiet is given, each skip is reported so the
> user knows why their topic was kept.
> @@ Documentation/config/branch.adoc: for details).
> automatically added to the `format-patch` cover letter or
> `request-pull` summary.
> +
> -+`branch.<name>.pruneMerged`::
> ++`branch.<name>.deleteMerged`::
> + If set to `false`, branch _<name>_ is exempt from
> -+ `git branch --prune-merged`. Useful for a topic branch you
> ++ `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.
>
> ## Documentation/git-branch.adoc ##
> -@@ Documentation/git-branch.adoc: the upstream refs refreshed.
> +@@ Documentation/git-branch.adoc: A branch is not deleted when:
> +
> - A branch is left alone if any of the following holds:
> - its upstream no longer resolves locally; it is checked out in any
> --worktree; or its push destination (`<branch>@{push}`) equals its
> -+worktree; its push destination (`<branch>@{push}`) equals its
> - upstream (`<branch>@{upstream}`), so it cannot be distinguished
> --from a freshly pulled trunk that just looks "fully merged".
> -+from a freshly pulled trunk that just looks "fully merged"; or
> -+`branch.<name>.pruneMerged` is set to `false`.
> + --
> + * 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 (`<branch>@{push}`) equals its upstream
> + (`<branch>@{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.<name>.deleteMerged` 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
> + A branch whose work has not yet been merged into its upstream is
>
> ## builtin/branch.c ##
> -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
> +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
> const char *short_name;
> struct branch *branch;
> const char *upstream, *push;
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
> + int opt_out;
>
> if (!skip_prefix(full_name, "refs/heads/", &short_name))
> - continue;
> -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
> + BUG("filter returned non-branch ref '%s'", full_name);
> +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
> if (!push || !strcmp(push, upstream))
> continue;
>
> -+ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
> ++ 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.pruneMerged is false)\n"),
> ++ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
> + short_name, short_name);
> + strbuf_release(&key);
> + continue;
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
>
>
> ## t/t3200-branch.sh ##
> -@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '
> +@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '
> test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
> '
>
> -+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> ++test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
> + test_when_finished "rm -rf pm-optout" &&
> + git clone pm-upstream pm-optout &&
> + git -C pm-optout remote add fork ../pm-fork &&
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch>
> + 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 &&
> ++ test_config -C pm-optout branch.one.deleteMerged false &&
> +
> -+ git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> ++ git -C pm-optout branch --delete-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_expect_success 'branch -d still deletes a deleteMerged=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 &&
> ++ test_config -C pm-optout-d branch.one.deleteMerged false &&
> +
> + git -C pm-optout-d branch -d one &&
> + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> 6: ede8c61729 ! 7: 7b2b01b988 branch: add --dry-run for --prune-merged
> @@ Metadata
> Author: Harald Nordgren <haraldnordgren@gmail.com>
>
> ## Commit message ##
> - branch: add --dry-run for --prune-merged
> + branch: add --dry-run for --delete-merged
>
> - With --dry-run, --prune-merged prints the local branches it would
> + With --dry-run, --delete-merged prints the local branches it would
> delete, one "Would delete branch <name>" line each, and exits
> without touching any ref. The same filtering applies, so the output
> is exactly the set that the real run would delete.
>
> - --dry-run is only meaningful together with --prune-merged and is
> + --dry-run is only meaningful together with --delete-merged and is
> rejected otherwise.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
> git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> --git branch --prune-merged <branch>...
> -+git branch [--dry-run] --prune-merged <branch>...
> +-git branch --delete-merged <branch>...
> ++git branch [--dry-run] --delete-merged <branch>...
>
> DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: 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.
> +@@ Documentation/git-branch.adoc: 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.
>
> +`--dry-run`::
> -+ With `--prune-merged`, print which branches would be
> ++ 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.
> @@ builtin/branch.c
> @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
> }
>
> - static int prune_merged_branches(int argc, const char **argv,
> + static int delete_merged_branches(int argc, const char **argv,
> - int quiet)
> + int quiet, int dry_run)
> {
> struct ref_store *refs = get_main_ref_store(the_repository);
> struct ref_filter filter = REF_FILTER_INIT;
> -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
> +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
> FILTER_REFS_BRANCHES,
> - DELETE_BRANCH_WARN_ONLY |
> + DELETE_BRANCH_SKIP_UNMERGED |
> DELETE_BRANCH_NO_HEAD_FALLBACK |
> - (quiet ? DELETE_BRANCH_QUIET : 0));
> + (quiet ? DELETE_BRANCH_QUIET : 0) |
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
> @@ builtin/branch.c: int cmd_branch(int argc,
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> - int prune_merged = 0;
> + int delete_merged = 0;
> + int dry_run = 0;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ builtin/branch.c: int cmd_branch(int argc,
> N_("edit the description for the branch")),
> - OPT_BOOL(0, "prune-merged", &prune_merged,
> - N_("delete local branches whose upstream matches <branch> and is merged")),
> + OPT_BOOL(0, "delete-merged", &delete_merged,
> + N_("delete local branches whose upstream matches <branch> and are merged")),
> + OPT_BOOL(0, "dry-run", &dry_run,
> -+ N_("with --prune-merged, only print which branches would be deleted")),
> ++ 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")),
> @@ builtin/branch.c: int cmd_branch(int argc,
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
>
> -+ if (dry_run && !prune_merged)
> -+ die(_("--dry-run requires --prune-merged"));
> ++ if (dry_run && !delete_merged)
> ++ die(_("--dry-run requires --delete-merged"));
> +
> if (recurse_submodules_explicit) {
> if (!submodule_propagate_branches)
> @@ builtin/branch.c: int cmd_branch(int argc,
> @@ builtin/branch.c: int cmd_branch(int argc,
> (quiet ? DELETE_BRANCH_QUIET : 0));
> goto out;
> - } else if (prune_merged) {
> -- ret = prune_merged_branches(argc, argv, quiet);
> -+ ret = prune_merged_branches(argc, argv, quiet, dry_run);
> + } else if (delete_merged) {
> +- ret = delete_merged_branches(argc, argv, quiet);
> ++ ret = delete_merged_branches(argc, argv, quiet, dry_run);
> goto out;
> } else if (show_current) {
> print_current_branch_name();
>
> ## t/t3200-branch.sh ##
> -@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
> +@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
> + 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 &&
> ++ git -C pm-dry branch --dry-run --delete-merged "origin/*" >actual &&
> + test_grep "Would delete branch one " actual &&
> + test_grep "Would delete branch two " actual &&
> +
> @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
> + 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_expect_success '--delete-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 &&
> @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
> + 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 &&
> ++ git -C pm-dry-mixed branch --dry-run --delete-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_expect_success '--dry-run without --delete-merged is rejected' '
> + test_must_fail git -C forked branch --dry-run 2>err &&
> -+ test_grep "requires --prune-merged" err
> ++ test_grep "requires --delete-merged" err
> +'
> +
> test_done
>
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-17 10:01 ` [PATCH v15 0/7] branch: delete-merged Phillip Wood
@ 2026-06-17 11:17 ` Harald Nordgren
2026-06-17 14:21 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-17 11:17 UTC (permalink / raw)
To: Phillip Wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
On Wed, Jun 17, 2026 at 12:01 PM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Harald
>
> Our SubmittingPatches documentation recommends waiting for the
> discussion to settle before sending a new version. When you know someone
> is going send more comments on a series it is a good idea to wait for
> them before sending a new version to avoid too much churn on the list
> which makes it hard for people to keep up. I'm not going to read this
> version in detail because I know another version will be needed but I
> did spot a couple of things in the summary below.
Got it. I think I am waiting a fair bit between sending new versions.
My last version here was 2 days ago.
> Not changing force sounds like a bad idea. The whole point of unpacking
> the flags at the start of the function is to avoid accidental
> regressions. Unpacking the flags into separate variables means the rest
> of the function does not need to know that the function arguments have
> changed.
My reason for keeping it like this was to avoid the slightly awkward
double re-assignment of both flag and boolean:
```
case FILTER_REFS_REMOTES:
...
flags |= DELETE_BRANCH_FORCE;
force = true;
```
But your way is likely still better, because the definitions at the
top of the function are clearer.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-17 11:17 ` Harald Nordgren
@ 2026-06-17 14:21 ` Phillip Wood
2026-06-17 19:11 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-17 14:21 UTC (permalink / raw)
To: Harald Nordgren
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
On 17/06/2026 12:17, Harald Nordgren wrote:
> On Wed, Jun 17, 2026 at 12:01 PM Phillip Wood <phillip.wood123@gmail.com> wrote:
> >> Our SubmittingPatches documentation recommends waiting for the
>> discussion to settle before sending a new version. When you know someone
>> is going send more comments on a series it is a good idea to wait for
>> them before sending a new version to avoid too much churn on the list
>> which makes it hard for people to keep up. I'm not going to read this
>> version in detail because I know another version will be needed but I
>> did spot a couple of things in the summary below.
>
> Got it. I think I am waiting a fair bit between sending new versions.
> My last version here was 2 days ago.
Right but you sent that version a few hours after I'd posted a partial
review which concluded by saying I'd finish it the next day. If you send
a new version when you are waiting for further comments it clutters the
list because you know you're going to have to post another revision when
you get the rest of the comments. Anyone reviewing the interim version
is wasting their time. When you receive review comments, by all means
start thinking about them and updating your local copy but please don't
post a new version until the discussion on the previous version has
settled down.
>> Not changing force sounds like a bad idea. The whole point of unpacking
>> the flags at the start of the function is to avoid accidental
>> regressions. Unpacking the flags into separate variables means the rest
>> of the function does not need to know that the function arguments have
>> changed.
>
> My reason for keeping it like this was to avoid the slightly awkward
> double re-assignment of both flag and boolean:
>
> ```
> case FILTER_REFS_REMOTES:
> ...
> flags |= DELETE_BRANCH_FORCE;
> force = true;
> ```
>
> But your way is likely still better, because the definitions at the
> top of the function are clearer.
Oh, are we passing flags on to another function? If so I'd missed that
and it does complicate things as we don't want two sources of truth.
Thanks
Phillip
>
> Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-17 14:21 ` Phillip Wood
@ 2026-06-17 19:11 ` Harald Nordgren
2026-06-18 13:48 ` Phillip Wood
0 siblings, 1 reply; 189+ messages in thread
From: Harald Nordgren @ 2026-06-17 19:11 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
> Right but you sent that version a few hours after I'd posted a partial
> review which concluded by saying I'd finish it the next day. If you send
> a new version when you are waiting for further comments it clutters the
> list because you know you're going to have to post another revision when
> you get the rest of the comments. Anyone reviewing the interim version
> is wasting their time. When you receive review comments, by all means
> start thinking about them and updating your local copy but please don't
> post a new version until the discussion on the previous version has
> settled down.
That's fair. Sorry about that.
Will you let me know when your review here is finished?
I received the same feedback from Junio before, so I'm not unaware of
this problem. I am trying to slow down. I often prepare the work as
soon as I get some comments -- I'm on paternity leave so I have a lot
of time when the baby is sleeping -- then I actively hold off on
sending to not overload the rest of you. But at the same time I think
it's valuable to keep up a certain pace. It's a balancing act.
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-17 19:11 ` Harald Nordgren
@ 2026-06-18 13:48 ` Phillip Wood
2026-06-18 17:53 ` Harald Nordgren
0 siblings, 1 reply; 189+ messages in thread
From: Phillip Wood @ 2026-06-18 13:48 UTC (permalink / raw)
To: Harald Nordgren, phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
Hi Harald
On 17/06/2026 20:11, Harald Nordgren wrote:
>> Right but you sent that version a few hours after I'd posted a partial
>> review which concluded by saying I'd finish it the next day. If you send
>> a new version when you are waiting for further comments it clutters the
>> list because you know you're going to have to post another revision when
>> you get the rest of the comments. Anyone reviewing the interim version
>> is wasting their time. When you receive review comments, by all means
>> start thinking about them and updating your local copy but please don't
>> post a new version until the discussion on the previous version has
>> settled down.
>
> That's fair. Sorry about that.
>
> Will you let me know when your review here is finished?
I've just sent a mail with another comment but that concudes this round
unless you have any questions about it.
>
> I received the same feedback from Junio before, so I'm not unaware of
> this problem. I am trying to slow down. I often prepare the work as
> soon as I get some comments -- I'm on paternity leave so I have a lot
> of time when the baby is sleeping --
Congratulations - I hope the baby is sleeping at night as well in the day!
> then I actively hold off on
> sending to not overload the rest of you. But at the same time I think
> it's valuable to keep up a certain pace. It's a balancing act.
It is worth waiting for the discussion to settle on each round, I'll try
and be clear when I've finished looking at each revision. I'm sure other
folks would appreciate you looking at their patches and commenting on
them while you're waiting for feedback on yours, especially the GSoC
project students.
Thanks
Phillip
^ permalink raw reply [flat|nested] 189+ messages in thread
* Re: [PATCH v15 0/7] branch: delete-merged
2026-06-18 13:48 ` Phillip Wood
@ 2026-06-18 17:53 ` Harald Nordgren
0 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren @ 2026-06-18 17:53 UTC (permalink / raw)
To: phillip.wood
Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
Johannes Sixt
> > I received the same feedback from Junio before, so I'm not unaware of
> > this problem. I am trying to slow down. I often prepare the work as
> > soon as I get some comments -- I'm on paternity leave so I have a lot
> > of time when the baby is sleeping --
>
> Congratulations - I hope the baby is sleeping at night as well in the day!
Thanks! It's our third, so hopefully we got the hang of it now.
He sleeps -- some of the time.
> > then I actively hold off on
> > sending to not overload the rest of you. But at the same time I think
> > it's valuable to keep up a certain pace. It's a balancing act.
> It is worth waiting for the discussion to settle on each round, I'll try
> and be clear when I've finished looking at each revision. I'm sure other
> folks would appreciate you looking at their patches and commenting on
> them while you're waiting for feedback on yours, especially the GSoC
> project students.
That's a good point!
Harald
^ permalink raw reply [flat|nested] 189+ messages in thread
* [PATCH v16 0/7] branch: delete-merged
2026-06-15 16:47 ` [PATCH v15 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (7 preceding siblings ...)
2026-06-17 10:01 ` [PATCH v15 0/7] branch: delete-merged Phillip Wood
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (7 more replies)
8 siblings, 8 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
Delete branches that have already been merged on upstream.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument
instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,
single strbuf_release after) instead of allocating and freeing it each
time
* Rewrite the --delete-merged tests as integration tests: branches that
land commits upstream, with deletion and the checked-out, upstream-gone,
and push-equals-upstream safety cases exercised together in one run and
output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up
by a setup_repo_for_delete_merged helper, and rename helpers off the old
pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of
prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily
final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of
warning.
* Initialized the delete_branches() flag locals where declared. Only force
stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f,
--forked coverage), renamed the misleading trunk fixture, and replaced
the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and
the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other
branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git
branch --prune-merged origin/main 'feature*') instead of repeating the
option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Changes in v11:
* The flags now take a branch, not a remote. --forked and --prune-merged
accept a literal upstream short name like origin/main or a wildmatch
pattern like origin/. The old --all-remotes flag is gone, since origin/
covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is
spared when these are equal. That is the trunk like case, such as local
main tracking and pushing to origin/main, where "fully merged to
upstream" cannot be told apart from "just pulled". Only branches that
push somewhere other than their upstream, typically fork based topics,
are candidates. The earlier /HEAD by name guard that the reviewer
rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its
upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream
first. If the upstream is gone, it checks against the remote's default
branch instead. If neither exists, the branch is refused (use --force to
delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override
that lets a caller substitute a different "what counts as merged"
reference (or skip the check). branch -d callers pass NULL and keep their
existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a
protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a
protected default ref and the local branch name matches the default
branch's leaf name (so a local main tracking origin/main is spared, but a
renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default
ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name
(not by upstream alone); spare a branch whose push ref is the remote
default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch
--prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config
options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the
given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their
tip is reachable from the upstream tracking ref; --force skips that
safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across
every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running
topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention
--prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 43 ++++-
builtin/branch.c | 186 ++++++++++++++++++----
ref-filter.c | 70 +++++++++
ref-filter.h | 10 ++
t/t3200-branch.sh | 262 +++++++++++++++++++++++++++++++
6 files changed, 550 insertions(+), 28 deletions(-)
base-commit: 4621f8ce5e9b97aa2e8d0d9ffe9d25df2471074d
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v16
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v16
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v15:
1: da741b5ea7 ! 1: 1f6a758265 branch: add --forked filter for --list mode
@@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
+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 &&
++ (
++ cd forked-upstream &&
++ test_commit base &&
++ git branch one base &&
++ git branch two base
++ ) &&
+
+ test_create_repo forked-other &&
-+ test_commit -C forked-other other-base &&
-+ git -C forked-other branch foreign other-base &&
++ (
++ cd forked-other &&
++ test_commit other-base &&
++ git branch foreign other-base
++ ) &&
+
+ git clone forked-upstream forked &&
-+ git -C forked remote add -f other ../forked-other &&
-+ git -C forked remote set-head origin one &&
-+ 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 --track local-onbase local-base &&
++ (
++ 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 -C forked checkout local-one &&
-+ test_commit -C forked --no-tag local-one-work local-one.t &&
-+ git -C forked checkout local-foreign &&
-+ test_commit -C forked --no-tag local-foreign-work local-foreign.t &&
-+ git -C forked checkout --detach
++ 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 <upstream-tracking-branch> filters by upstream' '
2: 91c35f10cc = 2: 4f8af602ba branch: convert delete_branches() to a flags argument
3: e101dd2886 = 3: efc891c255 branch: let delete_branches skip unmerged branches on bulk refusal
4: 6c3534901a = 4: b1ecd38fe3 branch: prepare delete_branches for a bulk caller
5: 5899013b8f ! 5: 998fb6a68c branch: add --delete-merged <branch>
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
}
+static int delete_merged_branches(int argc, const char **argv,
-+ int quiet)
++ unsigned int flags)
+{
+ struct ref_store *refs = get_main_ref_store(the_repository);
+ struct ref_filter filter = REF_FILTER_INIT;
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
-+ (quiet ? DELETE_BRANCH_QUIET : 0));
++ flags);
+
+ strvec_clear(&deletable);
+ ref_array_clear(&candidates);
@@ builtin/branch.c: int cmd_branch(int argc,
(quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
+ } else if (delete_merged) {
-+ ret = delete_merged_branches(argc, argv, quiet);
++ ret = delete_merged_branches(argc, argv,
++ quiet ? DELETE_BRANCH_QUIET : 0);
+ goto out;
} else if (show_current) {
print_current_branch_name();
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
'
+test_expect_success '--delete-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
++ 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
+'
+
-+test_expect_success '--delete-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 --delete-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 '--delete-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 --delete-merged origin/next &&
-+
-+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
-+'
-+
-+test_expect_success '--delete-merged unions multiple <branch> arguments' '
-+ test_when_finished "rm -rf pm-union" &&
-+ git clone pm-upstream pm-union &&
-+ git -C pm-union remote add fork ../pm-fork &&
-+ test_config -C pm-union remote.pushDefault fork &&
-+ test_config -C pm-union push.default current &&
-+ git -C pm-union branch one one-commit &&
-+ git -C pm-union branch --set-upstream-to=origin/next one &&
-+ git -C pm-union branch two base &&
-+ git -C pm-union branch --set-upstream-to=origin/main two &&
-+ git -C pm-union checkout --detach &&
-+
-+ git -C pm-union branch --delete-merged origin/next origin/main &&
-+
-+ test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
-+ test_must_fail git -C pm-union rev-parse --verify refs/heads/two
-+'
-+
-+test_expect_success '--delete-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 mainline &&
-+ git -C pm-local branch one one-commit &&
-+ git -C pm-local branch --set-upstream-to=mainline one &&
-+ git -C pm-local merge --ff-only one-commit &&
-+
-+ git -C pm-local branch --delete-merged mainline &&
-+
-+ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
-+'
-+
-+test_expect_success '--delete-merged silently skips 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 --delete-merged "origin/*" 2>err &&
-+ test_grep ! "not fully merged" err &&
-+ git -C pm-unmerged rev-parse --verify refs/heads/wip
-+'
-+
-+test_expect_success '--delete-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 --delete-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 '--delete-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 --delete-merged "origin/*" &&
-+
-+ git -C pm-upstream-gone rev-parse --verify refs/heads/one
-+'
-+
-+test_expect_success '--delete-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 --delete-merged "origin/*" &&
-+
-+ git -C pm-head rev-parse --verify refs/heads/one
-+'
-+
-+test_expect_success '--delete-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 --delete-merged "origin/*" &&
-+
-+ git -C pm-push-eq rev-parse --verify refs/heads/main
-+'
-+
-+test_expect_success '--delete-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 &&
++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
++ )
++}
+
-+ git -C pm-push-branch branch --delete-merged "origin/*" &&
++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"
++ )
++}
+
-+ git -C pm-push-branch rev-parse --verify refs/heads/main
++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 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 --delete-merged "origin/*" &&
-+
-+ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
++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 <branch>' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
-+
-+test_expect_success '--delete-merged takes positional <branch> arguments' '
-+ test_when_finished "rm -rf pm-positional" &&
-+ git clone pm-upstream pm-positional &&
-+ git -C pm-positional remote add fork ../pm-fork &&
-+ test_config -C pm-positional remote.pushDefault fork &&
-+ test_config -C pm-positional push.default current &&
-+ git -C pm-positional branch one one-commit &&
-+ git -C pm-positional branch --set-upstream-to=origin/next one &&
-+ git -C pm-positional branch two base &&
-+ git -C pm-positional branch --set-upstream-to=origin/main two &&
-+ git -C pm-positional checkout --detach &&
-+
-+ git -C pm-positional branch --delete-merged origin/next origin/main &&
-+
-+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
-+ test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
-+'
+
test_done
6: 72aaca0666 ! 6: a27d2724a2 branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/git-branch.adoc: A branch is not deleted when:
A branch whose work has not yet been merged into its upstream is
## builtin/branch.c ##
+@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ struct ref_filter filter = REF_FILTER_INIT;
+ struct ref_array candidates = { 0 };
+ struct strvec deletable = STRVEC_INIT;
++ struct strbuf key = STRBUF_INIT;
++ bool quiet = flags & DELETE_BRANCH_QUIET;
+ int i, ret = 0;
+
+ if (!argc)
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
const char *short_name;
struct branch *branch;
const char *upstream, *push;
-+ struct strbuf key = STRBUF_INIT;
+ int opt_out;
if (!skip_prefix(full_name, "refs/heads/", &short_name))
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
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) {
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ fprintf(stderr,
+ _("Skipping '%s' (branch.%s.deleteMerged is false)\n"),
+ short_name, short_name);
-+ strbuf_release(&key);
+ continue;
+ }
-+ strbuf_release(&key);
+
strvec_push(&deletable, short_name);
}
+@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
++ strbuf_release(&key);
+ strvec_clear(&deletable);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '
- test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires at least one <branch>' '
+ test_grep "requires at least one <branch>" err
'
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=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.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 pm-optout branch --delete-merged "origin/*" 2>err &&
++ git -C repo branch --delete-merged origin/next 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_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 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.deleteMerged false &&
++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 pm-optout-d branch -d one &&
-+ test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
++ git -C repo branch -d kept &&
++ test_must_fail git -C repo rev-parse --verify refs/heads/kept
+'
+
test_done
7: 7b2b01b988 ! 7: 6d5c52353e branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into
`--verbose`::
## builtin/branch.c ##
-@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
- }
-
- static int delete_merged_branches(int argc, const char **argv,
-- int quiet)
-+ int quiet, int dry_run)
- {
- struct ref_store *refs = get_main_ref_store(the_repository);
- struct ref_filter filter = REF_FILTER_INIT;
-@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
- FILTER_REFS_BRANCHES,
- DELETE_BRANCH_SKIP_UNMERGED |
- DELETE_BRANCH_NO_HEAD_FALLBACK |
-- (quiet ? DELETE_BRANCH_QUIET : 0));
-+ (quiet ? DELETE_BRANCH_QUIET : 0) |
-+ (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
-
- strvec_clear(&deletable);
- ref_array_clear(&candidates);
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
@@ builtin/branch.c: int cmd_branch(int argc,
if (!submodule_propagate_branches)
die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
@@ builtin/branch.c: int cmd_branch(int argc,
- (quiet ? DELETE_BRANCH_QUIET : 0));
goto out;
} else if (delete_merged) {
-- ret = delete_merged_branches(argc, argv, quiet);
-+ ret = delete_merged_branches(argc, argv, quiet, dry_run);
+ 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();
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=false branch' '
- test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+@@ t/t3200-branch.sh: 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 &&
+@@ t/t3200-branch.sh: 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 '--delete-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 --delete-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 '--delete-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 --delete-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 --delete-merged is rejected' '
+ test_must_fail git -C forked branch --dry-run 2>err &&
+ test_grep "requires --delete-merged" err
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v16 1/7] branch: add --forked filter for --list mode
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
` (6 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -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 _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`), a remote name like `origin` for the branch its
+ `origin/HEAD` points at, or a shell-style glob (e.g.
+ `'origin/*'`). The option can be repeated to widen the
+ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -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 <branch> (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 <branch> 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 <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-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 <remote> uses the branch <remote>/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 <pattern> 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 2/7] branch: convert delete_branches() to a flags argument
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() and check_branch_commit() take a pair of int
booleans (force and quiet) that the next commits would grow further.
Replace them with a single "unsigned int flags" argument and an
enum, splitting the bits back into named bool locals so the body
keeps reading the same named values.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a skip-unmerged mode to delete_branches() and check_branch_commit()
so a bulk caller can silently skip branches that are not fully merged
and carry on, rather than erroring with the "use 'git branch -D'"
advice that the plain "git branch -d" path emits. Existing callers are
unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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;
}
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 4/7] branch: prepare delete_branches for a bulk caller
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-18 19:25 ` [PATCH v16 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--delete-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
builtin/branch.c | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/builtin/branch.c b/builtin/branch.c
index 4c569d056a..1d3f28e4cb 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,8 @@ 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),
+ DELETE_BRANCH_DRY_RUN = (1 << 4),
};
static int check_branch_commit(const char *branchname, const char *refname,
@@ -241,6 +246,8 @@ 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 dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ 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 +275,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)) {
@@ -339,13 +346,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 5/7] branch: add --delete-merged <branch>
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-18 19:25 ` [PATCH v16 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
Three kinds of branches are not deleted:
* any branch checked out in any worktree
* any branch whose upstream remote-tracking branch no longer
exists, since a missing upstream is not by itself a sign of
integration
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is 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.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 24 ++++++++
builtin/branch.c | 67 +++++++++++++++++++++-
t/t3200-branch.sh | 105 ++++++++++++++++++++++++++++++++++
3 files changed, 194 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index b0d66a6deb..f82cfa36d0 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --delete-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--delete-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --delete-merged origin/main 'feature*'`.
++
+A branch is not deleted when:
++
+--
+* its upstream remote-tracking branch no longer exists,
+* it is checked out in any worktree, or
+* its push destination (`<branch>@{push}`) equals its upstream
+ (`<branch>@{upstream}`), so it cannot be distinguished from a
+ branch that just looks "fully merged" right after a pull.
+--
++
+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.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 1d3f28e4cb..e7e4f1d27f 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --delete-merged <branch>..."),
NULL
};
@@ -714,6 +715,60 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+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 strvec deletable = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--delete-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ 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;
+
+ strvec_push(&deletable, short_name);
+ }
+
+ if (deletable.nr)
+ ret = delete_branches(deletable.nr, deletable.v,
+ FILTER_REFS_BRANCHES,
+ DELETE_BRANCH_SKIP_UNMERGED |
+ DELETE_BRANCH_NO_HEAD_FALLBACK |
+ flags);
+
+ strvec_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)
@@ -755,6 +810,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 */
@@ -808,6 +864,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 <branch> 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")),
@@ -855,7 +913,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 ||
@@ -865,7 +924,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);
@@ -907,6 +966,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..609a67bb5a 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1839,4 +1839,109 @@ test_expect_success '--forked narrows a <pattern> 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 <branch>' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" err
+'
+
test_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 6/7] branch: add branch.<name>.deleteMerged opt-out
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-18 19:25 ` [PATCH v16 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-18 19:25 ` [PATCH v16 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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.<name>.deleteMerged`::
+ If set to `false`, branch _<name>_ 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 f82cfa36d0..91700f2e8a 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 (`<branch>@{push}`) equals its upstream
(`<branch>@{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.<name>.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 e7e4f1d27f..942e2297c8 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -722,6 +722,8 @@ static int delete_merged_branches(int argc, const char **argv,
struct ref_filter filter = REF_FILTER_INIT;
struct ref_array candidates = { 0 };
struct strvec deletable = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
if (!argc)
@@ -739,6 +741,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);
@@ -753,6 +756,17 @@ static int delete_merged_branches(int argc, const char **argv,
if (!push || !strcmp(push, upstream))
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;
+ }
+
strvec_push(&deletable, short_name);
}
@@ -763,6 +777,7 @@ static int delete_merged_branches(int argc, const char **argv,
DELETE_BRANCH_NO_HEAD_FALLBACK |
flags);
+ strbuf_release(&key);
strvec_clear(&deletable);
ref_array_clear(&candidates);
ref_filter_clear(&filter);
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 609a67bb5a..09cecfaff5 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1944,4 +1944,30 @@ test_expect_success '--delete-merged requires at least one <branch>' '
test_grep "requires at least one <branch>" err
'
+test_expect_success '--delete-merged honours branch.<name>.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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v16 7/7] branch: add --dry-run for --delete-merged
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-18 19:25 ` [PATCH v16 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-18 19:25 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
7 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-18 19:25 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --delete-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 8 +++++++-
builtin/branch.c | 9 ++++++++-
t/t3200-branch.sh | 11 ++++++++++-
3 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 91700f2e8a..09063d74f2 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --delete-merged <branch>...
+git branch [--dry-run] --delete-merged <branch>...
DESCRIPTION
-----------
@@ -226,6 +226,12 @@ 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.
+`--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 942e2297c8..f67d0949fe 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -826,6 +826,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 */
@@ -881,6 +882,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 <branch> 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")),
@@ -943,6 +946,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"));
@@ -983,7 +989,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 09cecfaff5..4a71845c76 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 &&
@@ -1970,4 +1974,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 0/7] branch: delete-merged
2026-06-18 19:25 ` [PATCH v16 " Harald Nordgren via GitGitGadget
` (6 preceding siblings ...)
2026-06-18 19:25 ` [PATCH v16 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
` (6 more replies)
7 siblings, 7 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren
Delete branches that have already been merged on upstream.
Changes in v17:
* Keep a merged branch when another surviving branch still tracks it as its
upstream, so --delete-merged no longer deletes a branch out from under
one stacked on top of it.
* Move the --dry-run and branch.<name>.deleteMerged opt-out fully into
their own commits.
Changes in v16:
* Convert delete_merged_branches() to take an unsigned int flags argument
instead of separate quiet/dry_run booleans, matching delete_branches()
* Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,
single strbuf_release after) instead of allocating and freeing it each
time
* Rewrite the --delete-merged tests as integration tests: branches that
land commits upstream, with deletion and the checked-out, upstream-gone,
and push-equals-upstream safety cases exercised together in one run and
output asserted via test_cmp
* Collapse the many per-aspect test repos into a single reused repo set up
by a setup_repo_for_delete_merged helper, and rename helpers off the old
pm_/prune naming
* Nest single-repo setup sequences in ( cd ... ) subshells instead of
prefixing every command with -C
Changes in v15:
* Renamed --prune-merged to --delete-merged throughout. Not necessarily
final, but something to advance the discussion.
* --delete-merged now silently skips not-yet-merged branches instead of
warning.
* Initialized the delete_branches() flag locals where declared. Only force
stays deferred.
* delete_branches()/check_branch_commit() doc and code cleanups: redundant
branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the
unreachable non-branch ref, and reworked --delete-merged doc wording.
* Broadened the --forked tests (local commits for realism, remote add -f,
--forked coverage), renamed the misleading trunk fixture, and replaced
the misnamed detached branch with git checkout --detach.
Changes in v14:
* Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
check_branch_commit() reads, so it wrongly ran the merge check.
* Made flags the single source of truth in delete_branches() so the bit and
the derived locals can't disagree.
* Works locally, but GitHub CI has problems that are there for other
branches too, hopefully not related
(https://github.com/git/git/pull/2285).
Changes in v13:
* Reworked --forked into a real ref-filter applied in apply_ref_filter()
instead of a post-pass, so non-matching branches are never allocated.
* Match exact --forked patterns on full refnames (only globs use the
abbreviated upstream), and dropped the old helper machinery, forward
declaration, and string_list in favor of a strvec.
* Replaced the boolean parameters of
delete_branches()/check_branch_commit() with a single unsigned int flags.
* --prune-merged now collects candidates via filter_refs() rather than its
own branch walk.
* --prune-merged now takes its patterns as positional arguments (e.g. git
branch --prune-merged origin/main 'feature*') instead of repeating the
option.
Changes in v12:
* Reworked --forked from a standalone action into a --list-mode filter.
* Switched --forked and --prune-merged to repeatable OPT_STRING_LIST
options.
* Dropped the bare-remote-name resolution for --forked, the argument is now
a ref or a glob.
Changes in v11:
* The flags now take a branch, not a remote. --forked and --prune-merged
accept a literal upstream short name like origin/main or a wildmatch
pattern like origin/. The old --all-remotes flag is gone, since origin/
covers that case.
* The prune guard now compares @{push} against @{upstream}. A branch is
spared when these are equal. That is the trunk like case, such as local
main tracking and pushing to origin/main, where "fully merged to
upstream" cannot be told apart from "just pulled". Only branches that
push somewhere other than their upstream, typically fork based topics,
are candidates. The earlier /HEAD by name guard that the reviewer
rejected is gone.
* New --dry-run for --prune-merged.
Changes in v10:
* --forked / --prune-merged now take a branch glob instead of a remote name
— origin, origin/*, origin/release-- all work. This replaces the
remote-only form and subsumes the old --all-remotes flag, which has been
dropped.
* New --dry-run for --prune-merged.
Changes in v9:
* --force no longer has special meaning with --prune-merged; reachability
is always enforced. Use git branch -D to delete an unmerged branch.
Matches how git branch's other read/safe actions treat --force.
* Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
* Dropped the --prune-merged --force tests.
Changes in v8:
* Delete only when the branch's work is actually reachable from its
upstream
* Skip branches whose upstream is gone (even with --force)
* Simplified the internal safety flag to live in one place
Changes in v7:
* --prune-merged now checks if a branch is merged into its own upstream
first. If the upstream is gone, it checks against the remote's default
branch instead. If neither exists, the branch is refused (use --force to
delete anyway).
Changes in v6:
* --prune-merged now measures merged-ness against the remote's default
branch instead of the candidate's upstream — so the decision no longer
depends on which branch happens to be checked out locally.
* delete_branches() / check_branch_commit() gained a per-candidate override
that lets a caller substitute a different "what counts as merged"
reference (or skip the check). branch -d callers pass NULL and keep their
existing semantics.
* prune_merged_branches() resolves each candidate's push-remote HEAD and
threads it through, so --prune-merged --all-remotes measures each
candidate against its own remote rather than a single global reference.
Changes in v5:
* Drop commit 'fetch: add --prune-merged'
Changes in v4:
* Resolve each remote's HEAD and collect the targets into a
protected_default_refs set in collect_forked_set.
* In prune_merged_branches, skip a candidate when its upstream is a
protected default ref and the local branch name matches the default
branch's leaf name (so a local main tracking origin/main is spared, but a
renamed trunk tracking origin/main is not).
* Also skip when the candidate's push ref points at a protected default
ref, so a topic branch configured to push to origin/main is never pruned.
* Tests: spare the local default branch; only protect by matching leaf name
(not by upstream alone); spare a branch whose push ref is the remote
default.
Changes in v3:
* s/remote-tracking refs/remote-tracking branches/g
Changes in v2:
* The whole feature moved out of git fetch and into git branch. git fetch
--prune-merged now just calls git branch --prune-merged after fetching.
* The fetch.pruneLocalBranches and remote..pruneLocalBranches config
options are gone, replaced by per-branch opt-out via branch..pruneMerged.
* New git branch --forked lists local branches whose upstream lives on the
given remote (read-only building block).
* New git branch --prune-merged deletes those branches, but only if their
tip is reachable from the upstream tracking ref; --force skips that
safety check.
* New git branch --all-remotes lets --forked/--prune-merged operate across
every configured remote at once.
* The currently checked-out branch in any worktree is always preserved.
* branch..pruneMerged=false lets you exempt a branch (e.g. a long-running
topic branch) even with --force; doesn't affect explicit git branch -d.
* delete_branches() got a warn_only mode so bulk deletion prints a one-line
warning per skipped branch instead of the noisy four-line hint that git
branch -d shows.
* New section in git-branch docs; git-fetch docs trimmed to just mention
--prune-merged.
* New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh
shrunk since most logic moved.
Harald Nordgren (7):
branch: add --forked filter for --list mode
branch: convert delete_branches() to a flags argument
branch: let delete_branches skip unmerged branches on bulk refusal
branch: prepare delete_branches for a bulk caller
branch: add --delete-merged <branch>
branch: add branch.<name>.deleteMerged opt-out
branch: add --dry-run for --delete-merged
Documentation/config/branch.adoc | 7 +
Documentation/git-branch.adoc | 47 ++++-
builtin/branch.c | 247 ++++++++++++++++++++++---
ref-filter.c | 70 +++++++
ref-filter.h | 10 +
t/t3200-branch.sh | 308 +++++++++++++++++++++++++++++++
6 files changed, 661 insertions(+), 28 deletions(-)
base-commit: 8d96f09e9245ddf80c1981476fcbac8c4bb4125f
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v17
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v17
Pull-Request: https://github.com/git/git/pull/2285
Range-diff vs v16:
1: 1f6a758265 = 1: d8cc17bd7f branch: add --forked filter for --list mode
2: 4f8af602ba = 2: d14b0403f0 branch: convert delete_branches() to a flags argument
3: efc891c255 = 3: ef2719dac3 branch: let delete_branches skip unmerged branches on bulk refusal
4: b1ecd38fe3 ! 4: 80518f5d11 branch: prepare delete_branches for a bulk caller
@@ builtin/branch.c: 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,
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
bool force;
bool quiet = flags & DELETE_BRANCH_QUIET;
bool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;
-+ bool dry_run = flags & DELETE_BRANCH_DRY_RUN;
+ bool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
struct strbuf bname = STRBUF_INIT;
enum interpret_branch_kind allowed_interpret;
@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki
head_rev = lookup_commit_reference(the_repository, &head_oid);
for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
-@@ builtin/branch.c: 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
5: 998fb6a68c ! 5: 46da7c8140 branch: add --delete-merged <branch>
@@ Commit message
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. Sparing such a base can in turn protect its own
+ upstream, so the check repeats until the set stops changing.
+
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
## Documentation/git-branch.adoc ##
@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
+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 still tracks as its upstream
++is kept, so a branch is never deleted out from under one stacked on
++top of it.
+
`-v`::
`-vv`::
`--verbose`::
## builtin/branch.c ##
+@@
+ #include "branch.h"
+ #include "path.h"
+ #include "string-list.h"
++#include "strmap.h"
+ #include "column.h"
+ #include "utf8.h"
+ #include "ref-filter.h"
@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
return 0;
}
++static int collect_upstream(const struct reference *ref, void *cb_data)
++{
++ struct string_list *upstreams = cb_data;
++ struct branch *branch = branch_get(ref->name);
++ const char *upstream = branch_get_upstream(branch, NULL);
++
++ string_list_append(upstreams, ref->name)->util =
++ xstrdup_or_null(upstream);
++ return 0;
++}
++
++/*
++ * Keep any branch that another, surviving branch tracks as its
++ * upstream, so we never delete a branch out from under one stacked on
++ * top of it. Sparing a branch makes it a survivor whose own upstream
++ * then needs the same protection, so repeat until nothing changes.
++ */
++static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
++{
++ struct string_list upstreams = STRING_LIST_INIT_DUP;
++ struct string_list_item *item;
++ bool spared;
++
++ refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
++ do {
++ spared = false;
++ for_each_string_list_item(item, &upstreams) {
++ const char *up = item->util, *up_short;
++
++ if (!up || strset_contains(deletable, item->string))
++ continue;
++ if (!skip_prefix(up, "refs/heads/", &up_short) ||
++ !strset_contains(deletable, up_short))
++ continue;
++
++ strset_remove(deletable, up_short);
++ spared = true;
++ }
++ } while (spared);
++
++ string_list_clear(&upstreams, 1);
++}
++
+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 strvec deletable = STRVEC_INIT;
++ struct strset deletable = STRSET_INIT;
++ struct strvec to_delete = STRVEC_INIT;
+ int i, ret = 0;
+
+ if (!argc)
@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch
+ 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;
+
-+ strvec_push(&deletable, short_name);
++ strset_add(&deletable, short_name);
+ }
+
-+ if (deletable.nr)
-+ ret = delete_branches(deletable.nr, deletable.v,
++ spare_stacked_bases(refs, &deletable);
++
++ for (i = 0; i < candidates.nr; i++) {
++ const char *short_name;
++
++ if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
++ &short_name) &&
++ strset_contains(&deletable, short_name))
++ strvec_push(&to_delete, short_name);
++ }
++
++ 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(&deletable);
++ strvec_clear(&to_delete);
++ strset_clear(&deletable);
+ ref_array_clear(&candidates);
+ ref_filter_clear(&filter);
+ return ret;
@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" 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 --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
++'
++
++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_done
6: a27d2724a2 ! 6: 27903fbb1d branch: add branch.<name>.deleteMerged opt-out
@@ Documentation/git-branch.adoc: A branch is not deleted when:
## builtin/branch.c ##
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
- struct ref_filter filter = REF_FILTER_INIT;
struct ref_array candidates = { 0 };
- struct strvec deletable = STRVEC_INIT;
+ struct strset deletable = STRSET_INIT;
+ struct strvec to_delete = STRVEC_INIT;
+ struct strbuf key = STRBUF_INIT;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
if (!skip_prefix(full_name, "refs/heads/", &short_name))
BUG("filter returned non-branch ref '%s'", full_name);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
- if (!push || !strcmp(push, upstream))
+ FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
continue;
+ strbuf_reset(&key);
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
+ continue;
+ }
+
- strvec_push(&deletable, short_name);
+ strset_add(&deletable, short_name);
}
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,
flags);
+ strbuf_release(&key);
- strvec_clear(&deletable);
+ strvec_clear(&to_delete);
+ strset_clear(&deletable);
ref_array_clear(&candidates);
- ref_filter_clear(&filter);
## t/t3200-branch.sh ##
-@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires at least one <branch>' '
- test_grep "requires at least one <branch>" err
+@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
+ test_cmp expect actual
'
+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '
7: 6d5c52353e ! 7: 49c1bcf1fb branch: add --dry-run for --delete-merged
@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
DESCRIPTION
-----------
-@@ Documentation/git-branch.adoc: 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.
+@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream
+ is kept, so a branch is never deleted out from under one stacked on
+ top of it.
+`--dry-run`::
+ With `--delete-merged`, print which branches would be
@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into
`--verbose`::
## builtin/branch.c ##
+@@ builtin/branch.c: 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,
+@@ builtin/branch.c: 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;
+@@ builtin/branch.c: 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
@@ builtin/branch.c: int cmd_branch(int argc,
int delete = 0, rename = 0, copy = 0, list = 0,
unset_upstream = 0, show_current = 0, edit_description = 0;
--
gitgitgadget
^ permalink raw reply [flat|nested] 189+ messages in thread* [PATCH v17 1/7] branch: add --forked filter for --list mode
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
` (5 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a --forked option to "git branch" list mode that lists only
branches whose configured upstream matches <branch>. The argument
can be a ref (e.g. "origin/main", "master"), a remote name like
"origin" for the branch its origin/HEAD points at, or a shell glob
(e.g. "origin/*"), and may be repeated to widen the filter.
It is an ordinary list filter, so it combines with the others:
git branch --merged origin/main --forked 'origin/*'
lists branches forked from origin that are already merged into
origin/main, and --no-merged inverts the question.
This is the building block for --delete-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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[=<when>] | --no-color] [--show-current]
[--column[=<options>] | --no-column] [--sort=<key>]
[--merged [<commit>]] [--no-merged [<commit>]]
[--contains [<commit>]] [--no-contains [<commit>]]
+ [(--forked <branch>)...]
[--points-at <object>] [--format=<format>]
[(-r|--remotes) | (-a|--all)]
[--list] [<pattern>...]
@@ -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 _<commit>_
argument is missing it defaults to `HEAD` (i.e. the tip of the current
-branch).
+branch). With `--forked`, only branches whose configured upstream matches
+the given branch or pattern will be listed.
The command's second form creates a new branch head named _<branch-name>_
which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
Only list branches whose tips are not reachable from
_<commit>_ (`HEAD` if not specified). Implies `--list`.
+`--forked <branch>`::
+ Only list branches whose configured upstream matches
+ _<branch>_. The argument can be a ref (e.g. `origin/main`,
+ `master`), a remote name like `origin` for the branch its
+ `origin/HEAD` points at, or a shell-style glob (e.g.
+ `'origin/*'`). The option can be repeated to widen the
+ filter. Implies `--list`.
+
`--points-at <object>`::
Only list branches of _<object>_.
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 [<options>] [-r | -a] [--merged] [--no-merged]"),
+ N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
N_("git branch [<options>] [-l] [<pattern>...]"),
N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -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 <branch> (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 <branch> 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 <upstream-tracking-branch> filters by upstream' '
+ git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+ echo local-one >expect &&
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+ git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+ cat >expect <<-\EOF &&
+ local-one
+ local-two
+ main
+ EOF
+ test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+ git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+ echo local-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 <remote> uses the branch <remote>/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 <pattern> 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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 2/7] branch: convert delete_branches() to a flags argument
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
` (4 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
delete_branches() and check_branch_commit() take a pair of int
booleans (force and quiet) that the next commits would grow further.
Replace them with a single "unsigned int flags" argument and an
enum, splitting the bits back into named bool locals so the body
keeps reading the same named values.
No change in behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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();
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 3/7] branch: let delete_branches skip unmerged branches on bulk refusal
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 1/7] branch: add --forked filter for --list mode Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 2/7] branch: convert delete_branches() to a flags argument Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
` (3 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Add a skip-unmerged mode to delete_branches() and check_branch_commit()
so a bulk caller can silently skip branches that are not fully merged
and carry on, rather than erroring with the "use 'git branch -D'"
advice that the plain "git branch -d" path emits. Existing callers are
unaffected.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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;
}
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 4/7] branch: prepare delete_branches for a bulk caller
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (2 preceding siblings ...)
2026-06-22 7:29 ` [PATCH v17 3/7] branch: let delete_branches skip unmerged branches on bulk refusal Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
` (2 subsequent siblings)
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Teach delete_branches() two new modes for the upcoming
--delete-merged: one that asks only whether a branch is merged into
its upstream, without falling back to HEAD when there is no
upstream, and one that rehearses the deletions without removing any
ref. Existing callers keep their current behavior.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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)) {
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 5/7] branch: add --delete-merged <branch>
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (3 preceding siblings ...)
2026-06-22 7:29 ` [PATCH v17 4/7] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
git branch --delete-merged <branch>...
deletes the local branches that "--forked <branch>" would list,
keeping only those whose tip is reachable from their configured
upstream. The work has already landed on the upstream they track,
so the local copy is no longer needed.
Three kinds of branches are not deleted:
* any branch checked out in any worktree
* any branch whose upstream remote-tracking branch no longer
exists, since a missing upstream is not by itself a sign of
integration
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{upstream}), such as
a local "main" that tracks and pushes to "origin/main". Right
after a pull it just looks "fully merged", so it is 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. Sparing such a base can in turn protect its own
upstream, so the check repeats until the set stops changing.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
Documentation/git-branch.adoc | 28 +++++++
builtin/branch.c | 128 +++++++++++++++++++++++++++-
t/t3200-branch.sh | 151 ++++++++++++++++++++++++++++++++++
3 files changed, 305 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index b0d66a6deb..56ff889447 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
+git branch --delete-merged <branch>...
DESCRIPTION
-----------
@@ -201,6 +202,33 @@ This option is only applicable in non-verbose mode.
Print the name of the current branch. In detached `HEAD` state,
nothing is printed.
+`--delete-merged <branch>...`::
+ Delete the local branches that `--forked` would list for the
+ given _<branch>_ arguments, but only those whose tip is
+ reachable from their configured upstream. In other words, the
+ work on the branch has already landed on the upstream it
+ tracks, so the local copy is no longer needed. Several
+ _<branch>_ patterns may be given, e.g. `git branch
+ --delete-merged origin/main 'feature*'`.
++
+A branch is not deleted when:
++
+--
+* its upstream remote-tracking branch no longer exists,
+* it is checked out in any worktree, or
+* its push destination (`<branch>@{push}`) equals its upstream
+ (`<branch>@{upstream}`), so it cannot be distinguished from a
+ branch that just looks "fully merged" right after a pull.
+--
++
+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 still tracks as its upstream
+is kept, so a branch is never deleted out from under one stacked on
+top of it.
+
`-v`::
`-vv`::
`--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 01c1f64c73..35fd3e9efc 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 [<options>] (-c | -C) [<old-branch>] <new-branch>"),
N_("git branch [<options>] [-r | -a] [--points-at]"),
N_("git branch [<options>] [-r | -a] [--format]"),
+ N_("git branch [<options>] --delete-merged <branch>..."),
NULL
};
@@ -705,6 +707,120 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
return 0;
}
+static int collect_upstream(const struct reference *ref, void *cb_data)
+{
+ struct string_list *upstreams = cb_data;
+ struct branch *branch = branch_get(ref->name);
+ const char *upstream = branch_get_upstream(branch, NULL);
+
+ string_list_append(upstreams, ref->name)->util =
+ xstrdup_or_null(upstream);
+ return 0;
+}
+
+/*
+ * Keep any branch that another, surviving branch tracks as its
+ * upstream, so we never delete a branch out from under one stacked on
+ * top of it. Sparing a branch makes it a survivor whose own upstream
+ * then needs the same protection, so repeat until nothing changes.
+ */
+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)
+{
+ struct string_list upstreams = STRING_LIST_INIT_DUP;
+ struct string_list_item *item;
+ bool spared;
+
+ refs_for_each_branch_ref(refs, collect_upstream, &upstreams);
+ do {
+ spared = false;
+ for_each_string_list_item(item, &upstreams) {
+ const char *up = item->util, *up_short;
+
+ if (!up || strset_contains(deletable, item->string))
+ continue;
+ if (!skip_prefix(up, "refs/heads/", &up_short) ||
+ !strset_contains(deletable, up_short))
+ continue;
+
+ strset_remove(deletable, up_short);
+ spared = true;
+ }
+ } while (spared);
+
+ string_list_clear(&upstreams, 1);
+}
+
+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;
+ int i, ret = 0;
+
+ if (!argc)
+ die(_("--delete-merged requires at least one <branch>"));
+
+ for (i = 0; i < argc; i++)
+ if (ref_filter_forked_add(&filter, argv[i]) < 0)
+ die(_("'%s' is not a valid branch or pattern"), argv[i]);
+
+ filter.kind = FILTER_REFS_BRANCHES;
+ 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);
+
+ for (i = 0; i < candidates.nr; i++) {
+ const char *short_name;
+
+ if (skip_prefix(candidates.items[i]->refname, "refs/heads/",
+ &short_name) &&
+ strset_contains(&deletable, short_name))
+ strvec_push(&to_delete, short_name);
+ }
+
+ 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 +862,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 +916,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 <branch> 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 +965,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 +976,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 +1018,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..1d372f95e8 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1839,4 +1839,155 @@ test_expect_success '--forked narrows a <pattern> 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 <branch>' '
+ test_must_fail git -C forked branch --delete-merged 2>err &&
+ test_grep "requires at least one <branch>" 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 --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
+'
+
+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_done
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 6/7] branch: add branch.<name>.deleteMerged opt-out
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (4 preceding siblings ...)
2026-06-22 7:29 ` [PATCH v17 5/7] branch: add --delete-merged <branch> Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
2026-06-22 7:29 ` [PATCH v17 7/7] branch: add --dry-run for --delete-merged Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.deleteMerged=false exempts that branch from
"git branch --delete-merged", which is useful for a topic you want
to keep developing after an early round of it has been merged
upstream. Unless --quiet is given, each skip is reported so the
user knows why their topic was kept.
Explicit deletion with "git branch -d" still uses the normal merge
check and ignores this setting.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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.<name>.deleteMerged`::
+ If set to `false`, branch _<name>_ 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 56ff889447..59ea3f471a 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 (`<branch>@{push}`) equals its upstream
(`<branch>@{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.<name>.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 35fd3e9efc..5ea610efa1 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -758,6 +758,8 @@ 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;
+ bool quiet = flags & DELETE_BRANCH_QUIET;
int i, ret = 0;
if (!argc)
@@ -775,6 +777,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);
@@ -793,6 +796,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);
}
@@ -814,6 +828,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 1d372f95e8..b80d558b4a 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1990,4 +1990,30 @@ test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch
test_cmp expect actual
'
+test_expect_success '--delete-merged honours branch.<name>.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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread* [PATCH v17 7/7] branch: add --dry-run for --delete-merged
2026-06-22 7:29 ` [PATCH v17 0/7] branch: delete-merged Harald Nordgren via GitGitGadget
` (5 preceding siblings ...)
2026-06-22 7:29 ` [PATCH v17 6/7] branch: add branch.<name>.deleteMerged opt-out Harald Nordgren via GitGitGadget
@ 2026-06-22 7:29 ` Harald Nordgren via GitGitGadget
6 siblings, 0 replies; 189+ messages in thread
From: Harald Nordgren via GitGitGadget @ 2026-06-22 7:29 UTC (permalink / raw)
To: git
Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
Harald Nordgren, Harald Nordgren
From: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --delete-merged prints the local branches it would
delete, one "Would delete branch <name>" line each, and exits
without touching any ref. The same filtering applies, so the output
is exactly the set that the real run would delete.
--dry-run is only meaningful together with --delete-merged and is
rejected otherwise.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
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 59ea3f471a..e9f43ffa9e 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
git branch (-c|-C) [<old-branch>] <new-branch>
git branch (-d|-D) [-r] <branch-name>...
git branch --edit-description [<branch-name>]
-git branch --delete-merged <branch>...
+git branch [--dry-run] --delete-merged <branch>...
DESCRIPTION
-----------
@@ -230,6 +230,12 @@ A branch that another, surviving branch still tracks as its upstream
is kept, so a branch is never deleted out from under one stacked on
top of it.
+`--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 5ea610efa1..5e326e6c30 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
@@ -878,6 +887,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 */
@@ -933,6 +943,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 <branch> 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")),
@@ -995,6 +1007,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"));
@@ -1035,7 +1050,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 b80d558b4a..211e13481c 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 &&
@@ -2016,4 +2020,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
--
gitgitgadget
^ permalink raw reply related [flat|nested] 189+ messages in thread