{"thread":{"id":"65574","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","startedAt":"2026-05-01T21:35:40Z","lastAt":"2026-08-07T13:09:18Z","messageCount":292,"participants":["Harald Nordgren via GitGitGadget","Junio C Hamano","Harald Nordgren","Kristoffer Haugsbakk","Johannes Sixt","Phillip Wood"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"542560","messageId":"pull.2285.git.git.1777671337839.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":null,"subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-01T21:35:37Z","receivedAt":"2026-05-01T21:35:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nIntroduce a tri-state config option that, when --prune (or\nfetch.prune / remote.<name>.prune) removes a remote-tracking\nref, also deletes local branches whose configured upstream is\nthat ref.\n\nValues:\n- false (default): no change in behavior.\n- safe: delete only if the local tip is reachable from the\n  upstream tip, preserving any unpushed work.\n- force: delete unconditionally; recoverable only via reflog.\n\nThe currently checked-out branch is always preserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n    fetch: add fetch.pruneBranches config\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v1\nPull-Request: https://github.com/git/git/pull/2285\n\n Documentation/config/fetch.adoc  |  39 +++++++\n Documentation/config/remote.adoc |   7 ++\n Documentation/fetch-options.adoc |   9 ++\n Documentation/git-fetch.adoc     |   6 ++\n builtin/fetch.c                  | 172 ++++++++++++++++++++++++++++++-\n remote.c                         |  16 +++\n remote.h                         |  10 ++\n t/t5510-fetch.sh                 |  84 +++++++++++++++\n 8 files changed, 339 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc\nindex cd40db0cad..5a60507a84 100644\n--- a/Documentation/config/fetch.adoc\n+++ b/Documentation/config/fetch.adoc\n@@ -50,6 +50,45 @@\n \trefs. See also `remote.<name>.pruneTags` and the PRUNING\n \tsection of linkgit:git-fetch[1].\n \n+`fetch.pruneBranches`::\n+\tWhen set in addition to `fetch.prune` (or `--prune`), also\n+\tdelete local branches whose configured upstream\n+\t(`branch.<name>.merge`) is one of the remote-tracking refs\n+\tjust removed by pruning. This is useful for cleaning up topic\n+\tbranches whose upstream counterpart has been merged and then\n+\tremoved. The same effect can be requested per-invocation with\n+\t`--prune-branches[=<mode>]`, or per-remote with\n+\t`remote.<name>.pruneBranches`.\n++\n+The currently checked-out branch (in any worktree) is never\n+deleted. The value is one of:\n++\n+--\n+`false` (the default);;\n+\tDo not delete any local branches. Equivalent to leaving\n+\tthe option unset.\n+`safe`;;\n+\tDelete a local branch only if its tip is an ancestor of\n+\tthe upstream remote-tracking ref's last-known position.\n+\tIn other words, only delete the branch if it contains no\n+\tcommits that the upstream did not also have at the moment\n+\tit was deleted. This catches the common case of a branch\n+\tthat was pushed and then squash- or rebase-merged\n+\tupstream (the local branch has no extra commits beyond\n+\twhat was pushed), but preserves any branch with unpushed\n+\tlocal work.\n+`force`;;\n+\tDelete the local branch unconditionally, even if it\n+\tcontains unpushed commits. Use with care: if a remote\n+\tbranch is deleted for any reason other than that its\n+\tcontents were merged, the corresponding local commits\n+\twill only be retrievable through the reflog.\n+--\n++\n+This option has no effect unless pruning is also enabled, since\n+local branches are only considered for deletion when their\n+upstream remote-tracking ref is being pruned in the same fetch.\n+\n `fetch.all`::\n \tIf true, fetch will attempt to update all available remotes.\n \tThis behavior can be overridden by passing `--no-all` or by\ndiff --git a/Documentation/config/remote.adoc b/Documentation/config/remote.adoc\nindex 91e46f66f5..60fd5841c6 100644\n--- a/Documentation/config/remote.adoc\n+++ b/Documentation/config/remote.adoc\n@@ -87,6 +87,13 @@ remote.<name>.pruneTags::\n See also `remote.<name>.prune` and the PRUNING section of\n linkgit:git-fetch[1].\n \n+remote.<name>.pruneBranches::\n+\tWhen pruning is active for this remote and this is set to `safe`\n+\tor `force`, also delete local branches whose upstream\n+\tremote-tracking ref is being pruned. Overrides\n+\t`fetch.pruneBranches` settings, if any. See `fetch.pruneBranches`\n+\tfor the meaning of the values.\n+\n remote.<name>.promisor::\n \tWhen set to true, this remote will be used to fetch promisor\n \tobjects.\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 81a9d7f9bb..0764f67cc3 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -185,6 +185,15 @@ See the PRUNING section below for more details.\n +\n See the PRUNING section below for more details.\n \n+`--prune-branches[=(safe|force)]`::\n+\tWhen pruning, also delete local branches whose configured\n+\tupstream (`branch.<name>.merge`) is one of the remote-tracking\n+\trefs being pruned. With no value or `safe`, refuse to delete a\n+\tbranch with unpushed commits; with `force`, delete it\n+\tregardless. The currently checked-out branch is never\n+\tdeleted. See `fetch.pruneBranches` in linkgit:git-config[1] for\n+\tdetails.\n+\n endif::git-pull[]\n \n ifndef::git-pull[]\ndiff --git a/Documentation/git-fetch.adoc b/Documentation/git-fetch.adoc\nindex db03541915..a50b9672a1 100644\n--- a/Documentation/git-fetch.adoc\n+++ b/Documentation/git-fetch.adoc\n@@ -179,6 +179,12 @@ It's reasonable to e.g. configure `fetch.pruneTags=true` in\n run, without making every invocation of `git fetch` without `--prune`\n an error.\n \n+Local branches whose upstream remote-tracking ref is being pruned can\n+also be deleted automatically with `--prune-branches[=<mode>]` (or its\n+config equivalents `fetch.pruneBranches` and `remote.<name>.pruneBranches`).\n+See linkgit:git-config[1] for the data-loss tradeoff between the\n+`safe` and `force` modes.\n+\n Pruning tags with `--prune-tags` also works when fetching a URL\n instead of a named remote. These will all prune tags not found on\n origin:\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..c6c2f00be0 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -82,6 +82,21 @@ static int prune = -1; /* unspecified */\n static int prune_tags = -1; /* unspecified */\n #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */\n \n+static int prune_branches = PRUNE_BRANCHES_UNSPECIFIED;\n+\n+static int parse_prune_branches_opt(const struct option *opt,\n+\t\t\t\t    const char *arg, int unset)\n+{\n+\tint *v = opt->value;\n+\tif (unset)\n+\t\t*v = PRUNE_BRANCHES_OFF;\n+\telse if (arg)\n+\t\t*v = parse_prune_branches_value(opt->long_name, arg);\n+\telse\n+\t\t*v = PRUNE_BRANCHES_SAFE;\n+\treturn 0;\n+}\n+\n static int append, dry_run, force, keep, update_head_ok;\n static int write_fetch_head = 1;\n static int verbosity, deepen_relative, set_upstream, refetch;\n@@ -105,6 +120,7 @@ struct fetch_config {\n \tint all;\n \tint prune;\n \tint prune_tags;\n+\tenum prune_branches_mode prune_branches;\n \tint show_forced_updates;\n \tint recurse_submodules;\n \tint parallel;\n@@ -131,6 +147,11 @@ static int git_fetch_config(const char *k, const char *v,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(k, \"fetch.prunebranches\")) {\n+\t\tfetch_config->prune_branches = parse_prune_branches_value(k, v);\n+\t\treturn 0;\n+\t}\n+\n \tif (!strcmp(k, \"fetch.showforcedupdates\")) {\n \t\tfetch_config->show_forced_updates = git_config_bool(k, v);\n \t\treturn 0;\n@@ -1445,7 +1466,8 @@ out:\n static int prune_refs(struct display_state *display_state,\n \t\t      struct refspec *rs,\n \t\t      struct ref_transaction *transaction,\n-\t\t      struct ref *ref_map)\n+\t\t      struct ref *ref_map,\n+\t\t      struct ref **stale_refs_out)\n {\n \tint result = 0;\n \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n@@ -1487,7 +1509,126 @@ static int prune_refs(struct display_state *display_state,\n cleanup:\n \tstring_list_clear(&refnames, 0);\n \tstrbuf_release(&err);\n-\tfree_refs(stale_refs);\n+\tif (!result && stale_refs_out)\n+\t\t*stale_refs_out = stale_refs;\n+\telse\n+\t\tfree_refs(stale_refs);\n+\treturn result;\n+}\n+\n+struct prune_branches_cb {\n+\tstruct string_list *pruned_refs;\n+\tstruct string_list *to_delete;\n+\tstruct string_list *skipped_unmerged;\n+\tenum prune_branches_mode mode;\n+};\n+\n+static int collect_branches_to_prune(const struct reference *ref, void *cb_data)\n+{\n+\tstruct prune_branches_cb *cb = cb_data;\n+\tconst char *short_name = ref->name;\n+\tchar *full_ref = xstrfmt(\"refs/heads/%s\", short_name);\n+\tconst char *upstream;\n+\tstruct string_list_item *pruned;\n+\tint result = 0;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\tgoto out;\n+\tif (branch_checked_out(full_ref))\n+\t\tgoto out;\n+\n+\tupstream = branch_get_upstream(branch_get(short_name), NULL);\n+\tif (!upstream)\n+\t\tgoto out;\n+\n+\tpruned = string_list_lookup(cb->pruned_refs, upstream);\n+\tif (!pruned)\n+\t\tgoto out;\n+\n+\tif (cb->mode == PRUNE_BRANCHES_SAFE) {\n+\t\tstruct commit *local = lookup_commit_reference(the_repository,\n+\t\t\t\t\t\t\t       ref->oid);\n+\t\tstruct commit *up = lookup_commit_reference(the_repository,\n+\t\t\t\t\t\t\t    pruned->util);\n+\t\tint reachable = local && up &&\n+\t\t\trepo_in_merge_bases(the_repository, local, up);\n+\n+\t\tif (reachable < 0) {\n+\t\t\tresult = -1;\n+\t\t\tgoto out;\n+\t\t}\n+\t\tif (!reachable) {\n+\t\t\tstring_list_append(cb->skipped_unmerged, short_name);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tstring_list_append(cb->to_delete, full_ref);\n+\n+out:\n+\tfree(full_ref);\n+\treturn result;\n+}\n+\n+static int do_prune_branches(struct display_state *display_state,\n+\t\t\t     struct ref *stale_refs,\n+\t\t\t     enum prune_branches_mode mode)\n+{\n+\tstruct string_list pruned_refs = STRING_LIST_INIT_NODUP;\n+\tstruct string_list to_delete = STRING_LIST_INIT_DUP;\n+\tstruct string_list skipped_unmerged = STRING_LIST_INIT_DUP;\n+\tstruct prune_branches_cb cb = {\n+\t\t.pruned_refs = &pruned_refs,\n+\t\t.to_delete = &to_delete,\n+\t\t.skipped_unmerged = &skipped_unmerged,\n+\t\t.mode = mode,\n+\t};\n+\tstruct ref *ref;\n+\tstruct string_list_item *item;\n+\tint result = 0;\n+\n+\tif (!stale_refs)\n+\t\treturn 0;\n+\n+\tfor (ref = stale_refs; ref; ref = ref->next)\n+\t\tstring_list_append(&pruned_refs, ref->name)->util = &ref->new_oid;\n+\tstring_list_sort(&pruned_refs);\n+\n+\tif (refs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t     collect_branches_to_prune, &cb)) {\n+\t\tresult = -1;\n+\t\tgoto cleanup;\n+\t}\n+\n+\tif (!dry_run && to_delete.nr)\n+\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n+\t\t\t\t\t  \"fetch: prune branches\",\n+\t\t\t\t\t  &to_delete, REF_NO_DEREF);\n+\n+\tif (verbosity >= 0) {\n+\t\tconst struct object_id *zero = null_oid(the_repository->hash_algo);\n+\t\tfor_each_string_list_item(item, &to_delete) {\n+\t\t\tconst char *short_name;\n+\t\t\tif (skip_prefix(item->string, \"refs/heads/\", &short_name))\n+\t\t\t\tdisplay_ref_update(display_state, '-',\n+\t\t\t\t\t\t   _(\"[deleted local]\"), NULL,\n+\t\t\t\t\t\t   _(\"(none)\"), short_name,\n+\t\t\t\t\t\t   zero, zero,\n+\t\t\t\t\t\t   transport_summary_width(NULL));\n+\t\t}\n+\t}\n+\tfor_each_string_list_item(item, &skipped_unmerged)\n+\t\twarning(_(\"not deleting local branch '%s' that is not \"\n+\t\t\t  \"fully merged into its upstream;\\n\"\n+\t\t\t  \"         set fetch.pruneBranches=force to \"\n+\t\t\t  \"delete anyway, or delete manually with \"\n+\t\t\t  \"'git branch -D %s'\"),\n+\t\t\titem->string, item->string);\n+\n+cleanup:\n+\tstring_list_clear(&pruned_refs, 0);\n+\tstring_list_clear(&to_delete, 0);\n+\tstring_list_clear(&skipped_unmerged, 0);\n \treturn result;\n }\n \n@@ -1945,19 +2086,28 @@ static int do_fetch(struct transport *transport,\n \tif (tags == TAGS_DEFAULT && autotags)\n \t\ttransport_set_option(transport, TRANS_OPT_FOLLOWTAGS, \"1\");\n \tif (prune) {\n+\t\tstruct ref *stale_refs = NULL;\n+\t\tstruct ref **stale_refs_out = prune_branches != PRUNE_BRANCHES_OFF\n+\t\t\t? &stale_refs : NULL;\n \t\t/*\n \t\t * We only prune based on refspecs specified\n \t\t * explicitly (via command line or configuration); we\n \t\t * don't care whether --tags was specified.\n \t\t */\n \t\tif (rs->nr) {\n-\t\t\tretcode = prune_refs(&display_state, rs, transaction, ref_map);\n+\t\t\tretcode = prune_refs(&display_state, rs, transaction,\n+\t\t\t\t\t     ref_map, stale_refs_out);\n \t\t} else {\n \t\t\tretcode = prune_refs(&display_state, &transport->remote->fetch,\n-\t\t\t\t\t     transaction, ref_map);\n+\t\t\t\t\t     transaction, ref_map, stale_refs_out);\n \t\t}\n \t\tif (retcode != 0)\n \t\t\tretcode = 1;\n+\t\telse if (stale_refs &&\n+\t\t\t do_prune_branches(&display_state, stale_refs,\n+\t\t\t\t\t   prune_branches))\n+\t\t\tretcode = 1;\n+\t\tfree_refs(stale_refs);\n \t}\n \n \t/*\n@@ -2419,6 +2569,16 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t\tprune_tags = PRUNE_TAGS_BY_DEFAULT;\n \t}\n \n+\tif (prune_branches == PRUNE_BRANCHES_UNSPECIFIED) {\n+\t\t/* no command line request */\n+\t\tif (remote->prune_branches >= 0)\n+\t\t\tprune_branches = remote->prune_branches;\n+\t\telse if (config->prune_branches >= 0)\n+\t\t\tprune_branches = config->prune_branches;\n+\t\telse\n+\t\t\tprune_branches = PRUNE_BRANCHES_OFF;\n+\t}\n+\n \tmaybe_prune_tags = prune_tags_ok && prune_tags;\n \tif (maybe_prune_tags && remote_via_config)\n \t\trefspec_append(&remote->fetch, TAG_REFSPEC);\n@@ -2469,6 +2629,7 @@ int cmd_fetch(int argc,\n \t\t.display_format = DISPLAY_FORMAT_FULL,\n \t\t.prune = -1,\n \t\t.prune_tags = -1,\n+\t\t.prune_branches = PRUNE_BRANCHES_UNSPECIFIED,\n \t\t.show_forced_updates = 1,\n \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n \t\t.parallel = 1,\n@@ -2520,6 +2681,9 @@ int cmd_fetch(int argc,\n \t\t\t N_(\"prune remote-tracking branches no longer on remote\")),\n \t\tOPT_BOOL('P', \"prune-tags\", &prune_tags,\n \t\t\t N_(\"prune local tags no longer on remote and clobber changed tags\")),\n+\t\tOPT_CALLBACK_F(0, \"prune-branches\", &prune_branches, N_(\"mode\"),\n+\t\t\t       N_(\"delete local branches whose upstream was pruned ('safe' or 'force')\"),\n+\t\t\t       PARSE_OPT_OPTARG, parse_prune_branches_opt),\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\ndiff --git a/remote.c b/remote.c\nindex a664cd166a..1e2b4803e7 100644\n--- a/remote.c\n+++ b/remote.c\n@@ -148,6 +148,7 @@ static struct remote *make_remote(struct remote_state *remote_state,\n \tCALLOC_ARRAY(ret, 1);\n \tret->prune = -1;  /* unspecified */\n \tret->prune_tags = -1;  /* unspecified */\n+\tret->prune_branches = -1;  /* unspecified */\n \tret->name = xstrndup(name, len);\n \trefspec_init_push(&ret->push);\n \trefspec_init_fetch(&ret->fetch);\n@@ -423,6 +424,19 @@ out:\n }\n #endif /* WITH_BREAKING_CHANGES */\n \n+int parse_prune_branches_value(const char *k, const char *v)\n+{\n+\tif (v) {\n+\t\tif (!strcasecmp(v, \"safe\"))\n+\t\t\treturn PRUNE_BRANCHES_SAFE;\n+\t\tif (!strcasecmp(v, \"force\"))\n+\t\t\treturn PRUNE_BRANCHES_FORCE;\n+\t}\n+\tif (git_parse_maybe_bool(v) == 0)\n+\t\treturn PRUNE_BRANCHES_OFF;\n+\tdie(_(\"invalid value for '%s': '%s'\"), k, v);\n+}\n+\n static int handle_config(const char *key, const char *value,\n \t\t\t const struct config_context *ctx, void *cb)\n {\n@@ -507,6 +521,8 @@ static int handle_config(const char *key, const char *value,\n \t\tremote->prune = git_config_bool(key, value);\n \telse if (!strcmp(subkey, \"prunetags\"))\n \t\tremote->prune_tags = git_config_bool(key, value);\n+\telse if (!strcmp(subkey, \"prunebranches\"))\n+\t\tremote->prune_branches = parse_prune_branches_value(key, value);\n \telse if (!strcmp(subkey, \"url\")) {\n \t\tif (!value)\n \t\t\treturn config_error_nonbool(key);\ndiff --git a/remote.h b/remote.h\nindex fc052945ee..5b750c8229 100644\n--- a/remote.h\n+++ b/remote.h\n@@ -28,6 +28,15 @@ enum {\n #endif /* WITH_BREAKING_CHANGES */\n };\n \n+enum prune_branches_mode {\n+\tPRUNE_BRANCHES_UNSPECIFIED = -1,\n+\tPRUNE_BRANCHES_OFF = 0,\n+\tPRUNE_BRANCHES_SAFE,\n+\tPRUNE_BRANCHES_FORCE,\n+};\n+\n+int parse_prune_branches_value(const char *k, const char *v);\n+\n struct rewrite {\n \tconst char *base;\n \tsize_t baselen;\n@@ -102,6 +111,7 @@ struct remote {\n \tint mirror;\n \tint prune;\n \tint prune_tags;\n+\tint prune_branches;\n \n \t/**\n \t * The configured helper programs to run on the remote side, for\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 6fe21e2b3a..5a2ff40132 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -386,6 +386,90 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '\n \t)\n '\n \n+test_expect_success 'fetch.pruneBranches: setup parent' '\n+\tgit init -b main prune-branches-parent &&\n+\ttest_commit -C prune-branches-parent base\n+'\n+\n+test_expect_success 'fetch.pruneBranches=safe deletes merged local branch' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-safe &&\n+\tgit -C prune-branches-safe checkout -b doomed --track origin/doomed &&\n+\tgit -C prune-branches-safe checkout -b stay &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-safe -c fetch.pruneBranches=safe fetch --prune origin &&\n+\ttest_must_fail git -C prune-branches-safe rev-parse refs/remotes/origin/doomed &&\n+\ttest_must_fail git -C prune-branches-safe rev-parse refs/heads/doomed\n+'\n+\n+test_expect_success 'fetch.pruneBranches=safe keeps unmerged local branch' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-safe-unmerged &&\n+\tgit -C prune-branches-safe-unmerged checkout -b doomed --track origin/doomed &&\n+\ttest_commit -C prune-branches-safe-unmerged local-only &&\n+\tgit -C prune-branches-safe-unmerged checkout -b stay &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-safe-unmerged -c fetch.pruneBranches=safe fetch --prune origin 2>err &&\n+\ttest_must_fail git -C prune-branches-safe-unmerged rev-parse refs/remotes/origin/doomed &&\n+\tgit -C prune-branches-safe-unmerged rev-parse refs/heads/doomed &&\n+\ttest_grep \"not fully merged\" err\n+'\n+\n+test_expect_success 'fetch.pruneBranches=force deletes unmerged local branch' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-force &&\n+\tgit -C prune-branches-force checkout -b doomed --track origin/doomed &&\n+\ttest_commit -C prune-branches-force local-only-force &&\n+\tgit -C prune-branches-force checkout -b stay &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-force -c fetch.pruneBranches=force fetch --prune origin &&\n+\ttest_must_fail git -C prune-branches-force rev-parse refs/remotes/origin/doomed &&\n+\ttest_must_fail git -C prune-branches-force rev-parse refs/heads/doomed\n+'\n+\n+test_expect_success 'fetch.pruneBranches=force never deletes checked-out branch' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-checked-out &&\n+\tgit -C prune-branches-checked-out checkout -b doomed --track origin/doomed &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-checked-out -c fetch.pruneBranches=force fetch --prune origin &&\n+\ttest_must_fail git -C prune-branches-checked-out rev-parse refs/remotes/origin/doomed &&\n+\tgit -C prune-branches-checked-out rev-parse refs/heads/doomed\n+'\n+\n+test_expect_success '--prune-branches deletes merged local branch' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-cli &&\n+\tgit -C prune-branches-cli checkout -b doomed --track origin/doomed &&\n+\tgit -C prune-branches-cli checkout -b stay &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-cli fetch --prune --prune-branches origin &&\n+\ttest_must_fail git -C prune-branches-cli rev-parse refs/heads/doomed\n+'\n+\n+test_expect_success '--no-prune-branches overrides fetch.pruneBranches' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-no-cli &&\n+\tgit -C prune-branches-no-cli checkout -b doomed --track origin/doomed &&\n+\tgit -C prune-branches-no-cli checkout -b stay &&\n+\tgit -C prune-branches-no-cli config fetch.pruneBranches force &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-no-cli fetch --prune --no-prune-branches origin &&\n+\tgit -C prune-branches-no-cli rev-parse refs/heads/doomed\n+'\n+\n+test_expect_success 'remote.<name>.pruneBranches overrides fetch.pruneBranches' '\n+\tgit -C prune-branches-parent branch doomed base &&\n+\tgit clone prune-branches-parent prune-branches-per-remote &&\n+\tgit -C prune-branches-per-remote checkout -b doomed --track origin/doomed &&\n+\tgit -C prune-branches-per-remote checkout -b stay &&\n+\tgit -C prune-branches-per-remote config fetch.pruneBranches force &&\n+\tgit -C prune-branches-per-remote config remote.origin.pruneBranches false &&\n+\tgit -C prune-branches-parent branch -D doomed &&\n+\tgit -C prune-branches-per-remote fetch --prune origin &&\n+\tgit -C prune-branches-per-remote rev-parse refs/heads/doomed\n+'\n+\n test_expect_success 'fetch --atomic works with a single branch' '\n \ttest_when_finished \"rm -rf atomic\" &&\n \n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\n-- \ngitgitgadget\n"},{"id":"542627","messageId":"xmqqfr48rubw.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.git.git.1777671337839.gitgitgadget@gmail.com","subject":"Re: [PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-03T22:39:47Z","receivedAt":"2026-05-03T22:39:49Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Introduce a tri-state config option that, when --prune (or\n> fetch.prune / remote.<name>.prune) removes a remote-tracking\n> ref, also deletes local branches whose configured upstream is\n> that ref.\n>\n> Values:\n> - false (default): no change in behavior.\n> - safe: delete only if the local tip is reachable from the\n>   upstream tip, preserving any unpushed work.\n> - force: delete unconditionally; recoverable only via reflog.\n>\n> The currently checked-out branch is always preserved.\n\nI do like the feature that allows you to identify which local\nbranches are already merged and prune them.  It will help users keep\ntheir local branch namespace clean.\n\nI however do not like to see the feature tied to \"fetch\".  By this,\nI do not mean I do not want an option to trigger the feature when\n\"git fetch\" is run.  What I mean is that users should have an option\nto prune merged branches without having to fetch first.  And you can\nthen optionally trigger that machinery from \"git fetch\".\n\nOf course they aleady can do something silly like\n\n    $ git branch -d $(git branch --list | sed -e 's/^..//')\n\nand remove all the merged branches, but compared to what is\npresented here, one thing missing is that you allow pruning the\nlocal branches that are merged only to remote-tracking branches from\na single remote.\n\nTo break the feature down to make it easier to use by our users with\nvarious needs and workflows, we would benefit from having a\ncollection of smaller features that can be composed, like these:\n\n * \"git branch --forked <remote>\" lists local branches that build on\n   something taken from <remote>s.  The option can be given multiple\n   times to make a union of the results from individual \"--forked\n   <remote>\".\n\n   - <remote> may be a name of a remote, e.g., \"origin\" to mean all\n     the remote-tracking branches \"refs/remotes/origin/*\", \n\n   - <remote> may be \"origin/master\" to name a specific\n     remote-tracking branch.\n\n   - There may be other handy things to cover with <remote>, like\n     \"--all\" that may act as if you listed all the available\n     <remote> on the command line.\n\n * \"git branch --prune-merged <remote>...\" is a short-hand for \"git\n   branch -d $(git branch --forked <remote>...\".\n\n * \"git fetch/pull --prune-merged <remote>\" can trigger \"git branch\n   --prune-merged <remote>\" after \"git fetch\" successfully updates\n   the remote-tracking branches, which should be equivalent to what\n   you have here..\n\nSome local branches that fork from remote and have their initial\nround already merged may not want to be pruned, however.  You may\nhave multi-stage development plans for that topic, and you know\nalready the second phase would want to build on top of the initial\nround, not a random version of the mainline with many topics from\nother folks merged in.  So you'd rather want to keep the topic\nbranch around after your initial round has been merged to the\nupstream before you start the second phase.  This is especially true\nif your topic is designed to apply to an existing release (in other\nwords, a bugfix) and you want to keep the second and subsequent\nrounds of the topic to be applicable to the same target version\nwithout contaminating the topic with irrelevant features from others\nthat happened to have been developed and merged upstream around the\nsame time.\n\nAnd we'd need to cater to their needs.  By this, I do not mean \"they\ndo not have to use --prune-merged\", but by giving them a way to say\n\"this branch should not be auto-pruned with --prune-merged\".\n"},{"id":"542709","messageId":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.git.git.1777671337839.gitgitgadget@gmail.com","subject":"[PATCH v2 0/6] fetch: add fetch.pruneBranches config","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:24Z","receivedAt":"2026-05-04T18:27:32Z","isPatch":true,"body":" * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote.<name>.pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via\n   branch.<name>.pruneMerged.\n * New git branch --forked <remote> lists local branches whose upstream\n   lives on the given remote (read-only building block).\n * New git branch --prune-merged <remote> deletes those branches, but only\n   if their tip is reachable from the upstream tracking ref; --force skips\n   that safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch.<name>.pruneMerged=false lets you exempt a branch (e.g. a\n   long-running topic branch) even with --force; doesn't affect explicit git\n   branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (6):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  fetch: add --prune-merged\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/fetch-options.adoc |   8 +\n Documentation/git-branch.adoc    |  32 ++++\n builtin/branch.c                 | 247 +++++++++++++++++++++++++++++--\n builtin/fetch.c                  |  20 +++\n t/t3200-branch.sh                | 215 +++++++++++++++++++++++++++\n t/t5510-fetch.sh                 |  31 ++++\n 7 files changed, 549 insertions(+), 11 deletions(-)\n\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v2\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v1:\n\n -:  ---------- > 1:  e9f8d06a2b branch: add --forked <remote>\n -:  ---------- > 2:  cd4a7e47af branch: let delete_branches warn instead of error on bulk refusal\n -:  ---------- > 3:  c0a5f69eb6 branch: add --prune-merged <remote>\n 1:  14e3085ed2 ! 4:  e979fd238b fetch: add fetch.pruneLocalBranches config\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    fetch: add fetch.pruneLocalBranches config\n     +    fetch: add --prune-merged\n      \n     -    Introduce a tri-state config option that, when --prune (or\n     -    fetch.prune / remote.<name>.prune) removes a remote-tracking\n     -    ref, also deletes local branches whose configured upstream is\n     -    that ref.\n     -\n     -    Values:\n     -    - false (default): no change in behavior.\n     -    - safe: delete only if the local tip is reachable from the\n     -      upstream tip, preserving any unpushed work.\n     -    - force: delete unconditionally; recoverable only via reflog.\n     -\n     -    The currently checked-out branch is always preserved.\n     +    After a successful fetch from a configured remote, run\n     +    'git branch --prune-merged <remote>' to delete local branches\n     +    whose push destination ref has just been pruned.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     - ## Documentation/config/fetch.adoc ##\n     -@@\n     - \trefs. See also `remote.<name>.pruneTags` and the PRUNING\n     - \tsection of linkgit:git-fetch[1].\n     - \n     -+`fetch.pruneBranches`::\n     -+\tWhen set in addition to `fetch.prune` (or `--prune`), also\n     -+\tdelete local branches whose configured upstream\n     -+\t(`branch.<name>.merge`) is one of the remote-tracking refs\n     -+\tjust removed by pruning. This is useful for cleaning up topic\n     -+\tbranches whose upstream counterpart has been merged and then\n     -+\tremoved. The same effect can be requested per-invocation with\n     -+\t`--prune-branches[=<mode>]`, or per-remote with\n     -+\t`remote.<name>.pruneBranches`.\n     -++\n     -+The currently checked-out branch (in any worktree) is never\n     -+deleted. The value is one of:\n     -++\n     -+--\n     -+`false` (the default);;\n     -+\tDo not delete any local branches. Equivalent to leaving\n     -+\tthe option unset.\n     -+`safe`;;\n     -+\tDelete a local branch only if its tip is an ancestor of\n     -+\tthe upstream remote-tracking ref's last-known position.\n     -+\tIn other words, only delete the branch if it contains no\n     -+\tcommits that the upstream did not also have at the moment\n     -+\tit was deleted. This catches the common case of a branch\n     -+\tthat was pushed and then squash- or rebase-merged\n     -+\tupstream (the local branch has no extra commits beyond\n     -+\twhat was pushed), but preserves any branch with unpushed\n     -+\tlocal work.\n     -+`force`;;\n     -+\tDelete the local branch unconditionally, even if it\n     -+\tcontains unpushed commits. Use with care: if a remote\n     -+\tbranch is deleted for any reason other than that its\n     -+\tcontents were merged, the corresponding local commits\n     -+\twill only be retrievable through the reflog.\n     -+--\n     -++\n     -+This option has no effect unless pruning is also enabled, since\n     -+local branches are only considered for deletion when their\n     -+upstream remote-tracking ref is being pruned in the same fetch.\n     -+\n     - `fetch.all`::\n     - \tIf true, fetch will attempt to update all available remotes.\n     - \tThis behavior can be overridden by passing `--no-all` or by\n     -\n     - ## Documentation/config/remote.adoc ##\n     -@@ Documentation/config/remote.adoc: remote.<name>.pruneTags::\n     - See also `remote.<name>.prune` and the PRUNING section of\n     - linkgit:git-fetch[1].\n     - \n     -+remote.<name>.pruneBranches::\n     -+\tWhen pruning is active for this remote and this is set to `safe`\n     -+\tor `force`, also delete local branches whose upstream\n     -+\tremote-tracking ref is being pruned. Overrides\n     -+\t`fetch.pruneBranches` settings, if any. See `fetch.pruneBranches`\n     -+\tfor the meaning of the values.\n     -+\n     - remote.<name>.promisor::\n     - \tWhen set to true, this remote will be used to fetch promisor\n     - \tobjects.\n     -\n       ## Documentation/fetch-options.adoc ##\n      @@ Documentation/fetch-options.adoc: See the PRUNING section below for more details.\n       +\n       See the PRUNING section below for more details.\n       \n     -+`--prune-branches[=(safe|force)]`::\n     -+\tWhen pruning, also delete local branches whose configured\n     -+\tupstream (`branch.<name>.merge`) is one of the remote-tracking\n     -+\trefs being pruned. With no value or `safe`, refuse to delete a\n     -+\tbranch with unpushed commits; with `force`, delete it\n     -+\tregardless. The currently checked-out branch is never\n     -+\tdeleted. See `fetch.pruneBranches` in linkgit:git-config[1] for\n     -+\tdetails.\n     ++`--prune-merged`::\n     ++\tAfter a successful fetch, run `git branch --prune-merged\n     ++\t<remote>` for the fetched remote, deleting local branches\n     ++\tthat fork from this remote and whose tip is reachable from\n     ++\ttheir upstream remote-tracking ref. See linkgit:git-branch[1]\n     ++\tfor the exact selection rules. The currently checked-out\n     ++\tbranch is always preserved.\n      +\n       endif::git-pull[]\n       \n       ifndef::git-pull[]\n      \n     - ## Documentation/git-fetch.adoc ##\n     -@@ Documentation/git-fetch.adoc: It's reasonable to e.g. configure `fetch.pruneTags=true` in\n     - run, without making every invocation of `git fetch` without `--prune`\n     - an error.\n     - \n     -+Local branches whose upstream remote-tracking ref is being pruned can\n     -+also be deleted automatically with `--prune-branches[=<mode>]` (or its\n     -+config equivalents `fetch.pruneBranches` and `remote.<name>.pruneBranches`).\n     -+See linkgit:git-config[1] for the data-loss tradeoff between the\n     -+`safe` and `force` modes.\n     -+\n     - Pruning tags with `--prune-tags` also works when fetching a URL\n     - instead of a named remote. These will all prune tags not found on\n     - origin:\n     -\n       ## builtin/fetch.c ##\n      @@ builtin/fetch.c: static int prune = -1; /* unspecified */\n       static int prune_tags = -1; /* unspecified */\n       #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */\n       \n     -+static int prune_branches = PRUNE_BRANCHES_UNSPECIFIED;\n     -+\n     -+static int parse_prune_branches_opt(const struct option *opt,\n     -+\t\t\t\t    const char *arg, int unset)\n     -+{\n     -+\tint *v = opt->value;\n     -+\tif (unset)\n     -+\t\t*v = PRUNE_BRANCHES_OFF;\n     -+\telse if (arg)\n     -+\t\t*v = parse_prune_branches_value(opt->long_name, arg);\n     -+\telse\n     -+\t\t*v = PRUNE_BRANCHES_SAFE;\n     -+\treturn 0;\n     -+}\n     ++static int prune_merged;\n      +\n       static int append, dry_run, force, keep, update_head_ok;\n       static int write_fetch_head = 1;\n       static int verbosity, deepen_relative, set_upstream, refetch;\n     -@@ builtin/fetch.c: struct fetch_config {\n     - \tint all;\n     - \tint prune;\n     - \tint prune_tags;\n     -+\tenum prune_branches_mode prune_branches;\n     - \tint show_forced_updates;\n     - \tint recurse_submodules;\n     - \tint parallel;\n     -@@ builtin/fetch.c: static int git_fetch_config(const char *k, const char *v,\n     - \t\treturn 0;\n     - \t}\n     +@@ builtin/fetch.c: static void add_options_to_argv(struct strvec *argv,\n     + \t\tstrvec_push(argv, prune ? \"--prune\" : \"--no-prune\");\n     + \tif (prune_tags != -1)\n     + \t\tstrvec_push(argv, prune_tags ? \"--prune-tags\" : \"--no-prune-tags\");\n     ++\tif (prune_merged)\n     ++\t\tstrvec_push(argv, \"--prune-merged\");\n     + \tif (update_head_ok)\n     + \t\tstrvec_push(argv, \"--update-head-ok\");\n     + \tif (force)\n     +@@ builtin/fetch.c: static inline void fetch_one_setup_partial(struct remote *remote,\n     + \treturn;\n     + }\n       \n     -+\tif (!strcmp(k, \"fetch.prunebranches\")) {\n     -+\t\tfetch_config->prune_branches = parse_prune_branches_value(k, v);\n     -+\t\treturn 0;\n     -+\t}\n     -+\n     - \tif (!strcmp(k, \"fetch.showforcedupdates\")) {\n     - \t\tfetch_config->show_forced_updates = git_config_bool(k, v);\n     - \t\treturn 0;\n     -@@ builtin/fetch.c: out:\n     - static int prune_refs(struct display_state *display_state,\n     - \t\t      struct refspec *rs,\n     - \t\t      struct ref_transaction *transaction,\n     --\t\t      struct ref *ref_map)\n     -+\t\t      struct ref *ref_map,\n     -+\t\t      struct ref **stale_refs_out)\n     - {\n     - \tint result = 0;\n     - \tstruct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);\n     -@@ builtin/fetch.c: static int prune_refs(struct display_state *display_state,\n     - cleanup:\n     - \tstring_list_clear(&refnames, 0);\n     - \tstrbuf_release(&err);\n     --\tfree_refs(stale_refs);\n     -+\tif (!result && stale_refs_out)\n     -+\t\t*stale_refs_out = stale_refs;\n     -+\telse\n     -+\t\tfree_refs(stale_refs);\n     -+\treturn result;\n     -+}\n     -+\n     -+struct prune_branches_cb {\n     -+\tstruct string_list *pruned_refs;\n     -+\tstruct string_list *to_delete;\n     -+\tstruct string_list *skipped_unmerged;\n     -+\tenum prune_branches_mode mode;\n     -+};\n     -+\n     -+static int collect_branches_to_prune(const struct reference *ref, void *cb_data)\n     ++static int prune_merged_for_remote(const struct remote *remote)\n      +{\n     -+\tstruct prune_branches_cb *cb = cb_data;\n     -+\tconst char *short_name = ref->name;\n     -+\tchar *full_ref = xstrfmt(\"refs/heads/%s\", short_name);\n     -+\tconst char *upstream;\n     -+\tstruct string_list_item *pruned;\n     -+\tint result = 0;\n     -+\n     -+\tif (ref->flags & REF_ISSYMREF)\n     -+\t\tgoto out;\n     -+\tif (branch_checked_out(full_ref))\n     -+\t\tgoto out;\n     -+\n     -+\tupstream = branch_get_upstream(branch_get(short_name), NULL);\n     -+\tif (!upstream)\n     -+\t\tgoto out;\n     ++\tstruct child_process cmd = CHILD_PROCESS_INIT;\n      +\n     -+\tpruned = string_list_lookup(cb->pruned_refs, upstream);\n     -+\tif (!pruned)\n     -+\t\tgoto out;\n     -+\n     -+\tif (cb->mode == PRUNE_BRANCHES_SAFE) {\n     -+\t\tstruct commit *local = lookup_commit_reference(the_repository,\n     -+\t\t\t\t\t\t\t       ref->oid);\n     -+\t\tstruct commit *up = lookup_commit_reference(the_repository,\n     -+\t\t\t\t\t\t\t    pruned->util);\n     -+\t\tint reachable = local && up &&\n     -+\t\t\trepo_in_merge_bases(the_repository, local, up);\n     -+\n     -+\t\tif (reachable < 0) {\n     -+\t\t\tresult = -1;\n     -+\t\t\tgoto out;\n     -+\t\t}\n     -+\t\tif (!reachable) {\n     -+\t\t\tstring_list_append(cb->skipped_unmerged, short_name);\n     -+\t\t\tgoto out;\n     -+\t\t}\n     -+\t}\n     -+\n     -+\tstring_list_append(cb->to_delete, full_ref);\n     -+\n     -+out:\n     -+\tfree(full_ref);\n     -+\treturn result;\n     ++\tcmd.git_cmd = 1;\n     ++\tstrvec_pushl(&cmd.args, \"branch\", \"--prune-merged\", remote->name, NULL);\n     ++\treturn run_command(&cmd);\n      +}\n      +\n     -+static int do_prune_branches(struct display_state *display_state,\n     -+\t\t\t     struct ref *stale_refs,\n     -+\t\t\t     enum prune_branches_mode mode)\n     -+{\n     -+\tstruct string_list pruned_refs = STRING_LIST_INIT_NODUP;\n     -+\tstruct string_list to_delete = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list skipped_unmerged = STRING_LIST_INIT_DUP;\n     -+\tstruct prune_branches_cb cb = {\n     -+\t\t.pruned_refs = &pruned_refs,\n     -+\t\t.to_delete = &to_delete,\n     -+\t\t.skipped_unmerged = &skipped_unmerged,\n     -+\t\t.mode = mode,\n     -+\t};\n     -+\tstruct ref *ref;\n     -+\tstruct string_list_item *item;\n     -+\tint result = 0;\n     -+\n     -+\tif (!stale_refs)\n     -+\t\treturn 0;\n     -+\n     -+\tfor (ref = stale_refs; ref; ref = ref->next)\n     -+\t\tstring_list_append(&pruned_refs, ref->name)->util = &ref->new_oid;\n     -+\tstring_list_sort(&pruned_refs);\n     -+\n     -+\tif (refs_for_each_branch_ref(get_main_ref_store(the_repository),\n     -+\t\t\t\t     collect_branches_to_prune, &cb)) {\n     -+\t\tresult = -1;\n     -+\t\tgoto cleanup;\n     -+\t}\n     -+\n     -+\tif (!dry_run && to_delete.nr)\n     -+\t\tresult = refs_delete_refs(get_main_ref_store(the_repository),\n     -+\t\t\t\t\t  \"fetch: prune branches\",\n     -+\t\t\t\t\t  &to_delete, REF_NO_DEREF);\n     + static int fetch_one(struct remote *remote, int argc, const char **argv,\n     + \t\t     int prune_tags_ok, int use_stdin_refspecs,\n     + \t\t     const struct fetch_config *config,\n     +@@ builtin/fetch.c: static int fetch_one(struct remote *remote, int argc, const char **argv,\n     + \trefspec_clear(&rs);\n     + \ttransport_disconnect(gtransport);\n     + \tgtransport = NULL;\n      +\n     -+\tif (verbosity >= 0) {\n     -+\t\tconst struct object_id *zero = null_oid(the_repository->hash_algo);\n     -+\t\tfor_each_string_list_item(item, &to_delete) {\n     -+\t\t\tconst char *short_name;\n     -+\t\t\tif (skip_prefix(item->string, \"refs/heads/\", &short_name))\n     -+\t\t\t\tdisplay_ref_update(display_state, '-',\n     -+\t\t\t\t\t\t   _(\"[deleted local]\"), NULL,\n     -+\t\t\t\t\t\t   _(\"(none)\"), short_name,\n     -+\t\t\t\t\t\t   zero, zero,\n     -+\t\t\t\t\t\t   transport_summary_width(NULL));\n     -+\t\t}\n     -+\t}\n     -+\tfor_each_string_list_item(item, &skipped_unmerged)\n     -+\t\twarning(_(\"not deleting local branch '%s' that is not \"\n     -+\t\t\t  \"fully merged into its upstream;\\n\"\n     -+\t\t\t  \"         set fetch.pruneBranches=force to \"\n     -+\t\t\t  \"delete anyway, or delete manually with \"\n     -+\t\t\t  \"'git branch -D %s'\"),\n     -+\t\t\titem->string, item->string);\n     ++\tif (!exit_code && prune_merged && remote_via_config &&\n     ++\t    prune_merged_for_remote(remote))\n     ++\t\texit_code = 1;\n      +\n     -+cleanup:\n     -+\tstring_list_clear(&pruned_refs, 0);\n     -+\tstring_list_clear(&to_delete, 0);\n     -+\tstring_list_clear(&skipped_unmerged, 0);\n     - \treturn result;\n     + \treturn exit_code;\n       }\n       \n     -@@ builtin/fetch.c: static int do_fetch(struct transport *transport,\n     - \tif (tags == TAGS_DEFAULT && autotags)\n     - \t\ttransport_set_option(transport, TRANS_OPT_FOLLOWTAGS, \"1\");\n     - \tif (prune) {\n     -+\t\tstruct ref *stale_refs = NULL;\n     -+\t\tstruct ref **stale_refs_out = prune_branches != PRUNE_BRANCHES_OFF\n     -+\t\t\t? &stale_refs : NULL;\n     - \t\t/*\n     - \t\t * We only prune based on refspecs specified\n     - \t\t * explicitly (via command line or configuration); we\n     - \t\t * don't care whether --tags was specified.\n     - \t\t */\n     - \t\tif (rs->nr) {\n     --\t\t\tretcode = prune_refs(&display_state, rs, transaction, ref_map);\n     -+\t\t\tretcode = prune_refs(&display_state, rs, transaction,\n     -+\t\t\t\t\t     ref_map, stale_refs_out);\n     - \t\t} else {\n     - \t\t\tretcode = prune_refs(&display_state, &transport->remote->fetch,\n     --\t\t\t\t\t     transaction, ref_map);\n     -+\t\t\t\t\t     transaction, ref_map, stale_refs_out);\n     - \t\t}\n     - \t\tif (retcode != 0)\n     - \t\t\tretcode = 1;\n     -+\t\telse if (stale_refs &&\n     -+\t\t\t do_prune_branches(&display_state, stale_refs,\n     -+\t\t\t\t\t   prune_branches))\n     -+\t\t\tretcode = 1;\n     -+\t\tfree_refs(stale_refs);\n     - \t}\n     - \n     - \t/*\n     -@@ builtin/fetch.c: static int fetch_one(struct remote *remote, int argc, const char **argv,\n     - \t\t\tprune_tags = PRUNE_TAGS_BY_DEFAULT;\n     - \t}\n     - \n     -+\tif (prune_branches == PRUNE_BRANCHES_UNSPECIFIED) {\n     -+\t\t/* no command line request */\n     -+\t\tif (remote->prune_branches >= 0)\n     -+\t\t\tprune_branches = remote->prune_branches;\n     -+\t\telse if (config->prune_branches >= 0)\n     -+\t\t\tprune_branches = config->prune_branches;\n     -+\t\telse\n     -+\t\t\tprune_branches = PRUNE_BRANCHES_OFF;\n     -+\t}\n     -+\n     - \tmaybe_prune_tags = prune_tags_ok && prune_tags;\n     - \tif (maybe_prune_tags && remote_via_config)\n     - \t\trefspec_append(&remote->fetch, TAG_REFSPEC);\n     -@@ builtin/fetch.c: int cmd_fetch(int argc,\n     - \t\t.display_format = DISPLAY_FORMAT_FULL,\n     - \t\t.prune = -1,\n     - \t\t.prune_tags = -1,\n     -+\t\t.prune_branches = PRUNE_BRANCHES_UNSPECIFIED,\n     - \t\t.show_forced_updates = 1,\n     - \t\t.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,\n     - \t\t.parallel = 1,\n      @@ builtin/fetch.c: int cmd_fetch(int argc,\n       \t\t\t N_(\"prune remote-tracking branches no longer on remote\")),\n       \t\tOPT_BOOL('P', \"prune-tags\", &prune_tags,\n       \t\t\t N_(\"prune local tags no longer on remote and clobber changed tags\")),\n     -+\t\tOPT_CALLBACK_F(0, \"prune-branches\", &prune_branches, N_(\"mode\"),\n     -+\t\t\t       N_(\"delete local branches whose upstream was pruned ('safe' or 'force')\"),\n     -+\t\t\t       PARSE_OPT_OPTARG, parse_prune_branches_opt),\n     ++\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     ++\t\t\t N_(\"after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream\")),\n       \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n       \t\t\t    N_(\"control recursive fetching of submodules\"),\n       \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\n      \n     - ## remote.c ##\n     -@@ remote.c: static struct remote *make_remote(struct remote_state *remote_state,\n     - \tCALLOC_ARRAY(ret, 1);\n     - \tret->prune = -1;  /* unspecified */\n     - \tret->prune_tags = -1;  /* unspecified */\n     -+\tret->prune_branches = -1;  /* unspecified */\n     - \tret->name = xstrndup(name, len);\n     - \trefspec_init_push(&ret->push);\n     - \trefspec_init_fetch(&ret->fetch);\n     -@@ remote.c: out:\n     - }\n     - #endif /* WITH_BREAKING_CHANGES */\n     - \n     -+int parse_prune_branches_value(const char *k, const char *v)\n     -+{\n     -+\tif (v) {\n     -+\t\tif (!strcasecmp(v, \"safe\"))\n     -+\t\t\treturn PRUNE_BRANCHES_SAFE;\n     -+\t\tif (!strcasecmp(v, \"force\"))\n     -+\t\t\treturn PRUNE_BRANCHES_FORCE;\n     -+\t}\n     -+\tif (git_parse_maybe_bool(v) == 0)\n     -+\t\treturn PRUNE_BRANCHES_OFF;\n     -+\tdie(_(\"invalid value for '%s': '%s'\"), k, v);\n     -+}\n     -+\n     - static int handle_config(const char *key, const char *value,\n     - \t\t\t const struct config_context *ctx, void *cb)\n     - {\n     -@@ remote.c: static int handle_config(const char *key, const char *value,\n     - \t\tremote->prune = git_config_bool(key, value);\n     - \telse if (!strcmp(subkey, \"prunetags\"))\n     - \t\tremote->prune_tags = git_config_bool(key, value);\n     -+\telse if (!strcmp(subkey, \"prunebranches\"))\n     -+\t\tremote->prune_branches = parse_prune_branches_value(key, value);\n     - \telse if (!strcmp(subkey, \"url\")) {\n     - \t\tif (!value)\n     - \t\t\treturn config_error_nonbool(key);\n     -\n     - ## remote.h ##\n     -@@ remote.h: enum {\n     - #endif /* WITH_BREAKING_CHANGES */\n     - };\n     - \n     -+enum prune_branches_mode {\n     -+\tPRUNE_BRANCHES_UNSPECIFIED = -1,\n     -+\tPRUNE_BRANCHES_OFF = 0,\n     -+\tPRUNE_BRANCHES_SAFE,\n     -+\tPRUNE_BRANCHES_FORCE,\n     -+};\n     -+\n     -+int parse_prune_branches_value(const char *k, const char *v);\n     -+\n     - struct rewrite {\n     - \tconst char *base;\n     - \tsize_t baselen;\n     -@@ remote.h: struct remote {\n     - \tint mirror;\n     - \tint prune;\n     - \tint prune_tags;\n     -+\tint prune_branches;\n     - \n     - \t/**\n     - \t * The configured helper programs to run on the remote side, for\n     -\n       ## t/t5510-fetch.sh ##\n      @@ t/t5510-fetch.sh: test_expect_success REFFILES 'fetch --prune fails to delete branches' '\n       \t)\n       '\n       \n     -+test_expect_success 'fetch.pruneBranches: setup parent' '\n     -+\tgit init -b main prune-branches-parent &&\n     -+\ttest_commit -C prune-branches-parent base\n     ++test_expect_success 'fetch --prune-merged: setup' '\n     ++\tgit init -b main fetch-pm-parent &&\n     ++\ttest_commit -C fetch-pm-parent base\n      +'\n      +\n     -+test_expect_success 'fetch.pruneBranches=safe deletes merged local branch' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-safe &&\n     -+\tgit -C prune-branches-safe checkout -b doomed --track origin/doomed &&\n     -+\tgit -C prune-branches-safe checkout -b stay &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-safe -c fetch.pruneBranches=safe fetch --prune origin &&\n     -+\ttest_must_fail git -C prune-branches-safe rev-parse refs/remotes/origin/doomed &&\n     -+\ttest_must_fail git -C prune-branches-safe rev-parse refs/heads/doomed\n     -+'\n     ++test_expect_success 'fetch --prune-merged deletes merged local branches' '\n     ++\ttest_when_finished \"rm -rf fetch-pm-clone\" &&\n     ++\tgit -C fetch-pm-parent branch one base &&\n     ++\tgit clone fetch-pm-parent fetch-pm-clone &&\n     ++\tgit -C fetch-pm-clone branch one --track origin/one &&\n     ++\tgit -C fetch-pm-parent branch -D one &&\n      +\n     -+test_expect_success 'fetch.pruneBranches=safe keeps unmerged local branch' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-safe-unmerged &&\n     -+\tgit -C prune-branches-safe-unmerged checkout -b doomed --track origin/doomed &&\n     -+\ttest_commit -C prune-branches-safe-unmerged local-only &&\n     -+\tgit -C prune-branches-safe-unmerged checkout -b stay &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-safe-unmerged -c fetch.pruneBranches=safe fetch --prune origin 2>err &&\n     -+\ttest_must_fail git -C prune-branches-safe-unmerged rev-parse refs/remotes/origin/doomed &&\n     -+\tgit -C prune-branches-safe-unmerged rev-parse refs/heads/doomed &&\n     -+\ttest_grep \"not fully merged\" err\n     -+'\n     -+\n     -+test_expect_success 'fetch.pruneBranches=force deletes unmerged local branch' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-force &&\n     -+\tgit -C prune-branches-force checkout -b doomed --track origin/doomed &&\n     -+\ttest_commit -C prune-branches-force local-only-force &&\n     -+\tgit -C prune-branches-force checkout -b stay &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-force -c fetch.pruneBranches=force fetch --prune origin &&\n     -+\ttest_must_fail git -C prune-branches-force rev-parse refs/remotes/origin/doomed &&\n     -+\ttest_must_fail git -C prune-branches-force rev-parse refs/heads/doomed\n     -+'\n     -+\n     -+test_expect_success 'fetch.pruneBranches=force never deletes checked-out branch' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-checked-out &&\n     -+\tgit -C prune-branches-checked-out checkout -b doomed --track origin/doomed &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-checked-out -c fetch.pruneBranches=force fetch --prune origin &&\n     -+\ttest_must_fail git -C prune-branches-checked-out rev-parse refs/remotes/origin/doomed &&\n     -+\tgit -C prune-branches-checked-out rev-parse refs/heads/doomed\n     -+'\n     -+\n     -+test_expect_success '--prune-branches deletes merged local branch' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-cli &&\n     -+\tgit -C prune-branches-cli checkout -b doomed --track origin/doomed &&\n     -+\tgit -C prune-branches-cli checkout -b stay &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-cli fetch --prune --prune-branches origin &&\n     -+\ttest_must_fail git -C prune-branches-cli rev-parse refs/heads/doomed\n     -+'\n     ++\tgit -C fetch-pm-clone fetch --prune --prune-merged origin &&\n      +\n     -+test_expect_success '--no-prune-branches overrides fetch.pruneBranches' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-no-cli &&\n     -+\tgit -C prune-branches-no-cli checkout -b doomed --track origin/doomed &&\n     -+\tgit -C prune-branches-no-cli checkout -b stay &&\n     -+\tgit -C prune-branches-no-cli config fetch.pruneBranches force &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-no-cli fetch --prune --no-prune-branches origin &&\n     -+\tgit -C prune-branches-no-cli rev-parse refs/heads/doomed\n     ++\ttest_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success 'remote.<name>.pruneBranches overrides fetch.pruneBranches' '\n     -+\tgit -C prune-branches-parent branch doomed base &&\n     -+\tgit clone prune-branches-parent prune-branches-per-remote &&\n     -+\tgit -C prune-branches-per-remote checkout -b doomed --track origin/doomed &&\n     -+\tgit -C prune-branches-per-remote checkout -b stay &&\n     -+\tgit -C prune-branches-per-remote config fetch.pruneBranches force &&\n     -+\tgit -C prune-branches-per-remote config remote.origin.pruneBranches false &&\n     -+\tgit -C prune-branches-parent branch -D doomed &&\n     -+\tgit -C prune-branches-per-remote fetch --prune origin &&\n     -+\tgit -C prune-branches-per-remote rev-parse refs/heads/doomed\n     ++test_expect_success 'fetch --prune-merged skips unmerged local branches' '\n     ++\ttest_when_finished \"rm -rf fetch-pm-unmerged\" &&\n     ++\tgit -C fetch-pm-parent branch two base &&\n     ++\tgit clone fetch-pm-parent fetch-pm-unmerged &&\n     ++\tgit -C fetch-pm-unmerged checkout -b two --track origin/two &&\n     ++\ttest_commit -C fetch-pm-unmerged unpushed &&\n     ++\tgit -C fetch-pm-unmerged checkout - &&\n     ++\tgit -C fetch-pm-parent branch -D two &&\n     ++\n     ++\tgit -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&\n     ++\ttest_grep \"not fully merged\" err &&\n     ++\tgit -C fetch-pm-unmerged rev-parse --verify refs/heads/two\n      +'\n      +\n       test_expect_success 'fetch --atomic works with a single branch' '\n -:  ---------- > 5:  0bc5ebbe68 branch: add branch.<name>.pruneMerged opt-out\n -:  ---------- > 6:  66dac97626 branch: add --all-remotes flag\n\n-- \ngitgitgadget\n"},{"id":"542710","messageId":"e9f8d06a2bbab155b66e89c467aaeb2f37d808ed.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 1/6] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:25Z","receivedAt":"2026-05-04T18:27:34Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking refs) or a single\nremote-tracking ref. Multiple <remote> arguments are unioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..a79f5552dd 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\trefs.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking ref\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..03b15452e9 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking ref\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..3e4c8e4473 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-ref> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking ref\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542711","messageId":"cd4a7e47af2d041f711fa2f3980ba19777d57d0b.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:26Z","receivedAt":"2026-05-04T18:27:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 03b15452e9..176dccb8be 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"542712","messageId":"c0a5f69eb6e0f5dcd4e58ea809b80d7771740541.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 3/6] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:27Z","receivedAt":"2026-05-04T18:27:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from its upstream\nremote-tracking ref. With --force, delete unconditionally. The\ncurrently checked-out branch in any worktree is always preserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc | 16 ++++++\n builtin/branch.c              | 97 ++++++++++++++++++++++++++++++-----\n t/t3200-branch.sh             | 81 +++++++++++++++++++++++++++++\n 3 files changed, 182 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex a79f5552dd..4995e03ef6 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking ref\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking ref (the ref `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+By default, the local tip must also be reachable from the\n+upstream remote-tracking ref (see `--no-merged`); branches with\n+unpushed commits are refused. With `--force` (or `-f`), delete\n+them regardless. The currently checked-out branch in any worktree\n+is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 176dccb8be..f50b7e222c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -753,36 +754,101 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +891,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +947,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +993,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1003,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -977,6 +1047,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3e4c8e4473..9e997b6be6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,85 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/main &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542713","messageId":"e979fd238bdffd772783f47f42fdc6d8c2dc0205.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 4/6] fetch: add --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:28Z","receivedAt":"2026-05-04T18:27:38Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAfter a successful fetch from a configured remote, run\n'git branch --prune-merged <remote>' to delete local branches\nwhose push destination ref has just been pruned.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/fetch-options.adoc |  8 ++++++++\n builtin/fetch.c                  | 20 ++++++++++++++++++++\n t/t5510-fetch.sh                 | 31 +++++++++++++++++++++++++++++++\n 3 files changed, 59 insertions(+)\n\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 81a9d7f9bb..d863a9184e 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -185,6 +185,14 @@ See the PRUNING section below for more details.\n +\n See the PRUNING section below for more details.\n \n+`--prune-merged`::\n+\tAfter a successful fetch, run `git branch --prune-merged\n+\t<remote>` for the fetched remote, deleting local branches\n+\tthat fork from this remote and whose tip is reachable from\n+\ttheir upstream remote-tracking ref. See linkgit:git-branch[1]\n+\tfor the exact selection rules. The currently checked-out\n+\tbranch is always preserved.\n+\n endif::git-pull[]\n \n ifndef::git-pull[]\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..5451bf3b5b 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -82,6 +82,8 @@ static int prune = -1; /* unspecified */\n static int prune_tags = -1; /* unspecified */\n #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */\n \n+static int prune_merged;\n+\n static int append, dry_run, force, keep, update_head_ok;\n static int write_fetch_head = 1;\n static int verbosity, deepen_relative, set_upstream, refetch;\n@@ -2189,6 +2191,8 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, prune ? \"--prune\" : \"--no-prune\");\n \tif (prune_tags != -1)\n \t\tstrvec_push(argv, prune_tags ? \"--prune-tags\" : \"--no-prune-tags\");\n+\tif (prune_merged)\n+\t\tstrvec_push(argv, \"--prune-merged\");\n \tif (update_head_ok)\n \t\tstrvec_push(argv, \"--update-head-ok\");\n \tif (force)\n@@ -2382,6 +2386,15 @@ static inline void fetch_one_setup_partial(struct remote *remote,\n \treturn;\n }\n \n+static int prune_merged_for_remote(const struct remote *remote)\n+{\n+\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\n+\tcmd.git_cmd = 1;\n+\tstrvec_pushl(&cmd.args, \"branch\", \"--prune-merged\", remote->name, NULL);\n+\treturn run_command(&cmd);\n+}\n+\n static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t     int prune_tags_ok, int use_stdin_refspecs,\n \t\t     const struct fetch_config *config,\n@@ -2457,6 +2470,11 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \trefspec_clear(&rs);\n \ttransport_disconnect(gtransport);\n \tgtransport = NULL;\n+\n+\tif (!exit_code && prune_merged && remote_via_config &&\n+\t    prune_merged_for_remote(remote))\n+\t\texit_code = 1;\n+\n \treturn exit_code;\n }\n \n@@ -2520,6 +2538,8 @@ int cmd_fetch(int argc,\n \t\t\t N_(\"prune remote-tracking branches no longer on remote\")),\n \t\tOPT_BOOL('P', \"prune-tags\", &prune_tags,\n \t\t\t N_(\"prune local tags no longer on remote and clobber changed tags\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\t N_(\"after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream\")),\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 6fe21e2b3a..b94fd2bda0 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -386,6 +386,37 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '\n \t)\n '\n \n+test_expect_success 'fetch --prune-merged: setup' '\n+\tgit init -b main fetch-pm-parent &&\n+\ttest_commit -C fetch-pm-parent base\n+'\n+\n+test_expect_success 'fetch --prune-merged deletes merged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-clone\" &&\n+\tgit -C fetch-pm-parent branch one base &&\n+\tgit clone fetch-pm-parent fetch-pm-clone &&\n+\tgit -C fetch-pm-clone branch one --track origin/one &&\n+\tgit -C fetch-pm-parent branch -D one &&\n+\n+\tgit -C fetch-pm-clone fetch --prune --prune-merged origin &&\n+\n+\ttest_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'fetch --prune-merged skips unmerged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-unmerged\" &&\n+\tgit -C fetch-pm-parent branch two base &&\n+\tgit clone fetch-pm-parent fetch-pm-unmerged &&\n+\tgit -C fetch-pm-unmerged checkout -b two --track origin/two &&\n+\ttest_commit -C fetch-pm-unmerged unpushed &&\n+\tgit -C fetch-pm-unmerged checkout - &&\n+\tgit -C fetch-pm-parent branch -D two &&\n+\n+\tgit -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\tgit -C fetch-pm-unmerged rev-parse --verify refs/heads/two\n+'\n+\n test_expect_success 'fetch --atomic works with a single branch' '\n \ttest_when_finished \"rm -rf atomic\" &&\n \n-- \ngitgitgadget\n\n"},{"id":"542714","messageId":"0bc5ebbe68c29b408f7f5b3656f23704a2645a12.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:29Z","receivedAt":"2026-05-04T18:27:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 17 +++++++-------\n builtin/branch.c                 | 23 ++++++++++++++++--\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 76 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..60dba38e27 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged` (and `git fetch --prune-merged`).\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 4995e03ef6..8d3e13d785 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,16 +216,15 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking ref (the ref `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n-By default, the local tip must also be reachable from the\n-upstream remote-tracking ref (see `--no-merged`); branches with\n-unpushed commits are refused. With `--force` (or `-f`), delete\n-them regardless. The currently checked-out branch in any worktree\n-is always preserved.\n+The local tip must also be reachable from the upstream\n+remote-tracking ref; branches with unpushed commits are refused.\n+With `--force` (or `-f`), delete them regardless. The currently\n+checked-out branch in any worktree is always preserved, as is\n+any branch with `branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f50b7e222c..74c8f1aedf 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -809,23 +809,42 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n \n \t\tbranch = branch_get(short_name);\n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 9e997b6be6..2127cf1fa5 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1852,4 +1852,44 @@ test_expect_success '--prune-merged deletes when push ref differs from upstream'\n \ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542715","messageId":"66dac9762638d6d62be7beec518c3b4d45c74955.1777919250.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v2 6/6] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-04T18:27:30Z","receivedAt":"2026-05-04T18:27:41Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 40 ++++++++++++++++++++++++-----------\n t/t3200-branch.sh             | 40 +++++++++++++++++++++++++++++++++++\n 3 files changed, 75 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 8d3e13d785..87efdefccb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,11 @@ With `--force` (or `-f`), delete them regardless. The currently\n checked-out branch in any worktree is always preserved, as is\n any branch with `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 74c8f1aedf..3d70357cf7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -685,6 +685,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -754,7 +761,7 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n@@ -766,6 +773,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -776,15 +785,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -792,8 +801,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct strvec deletable = STRVEC_INIT;\n@@ -801,10 +810,10 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -911,6 +920,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -968,6 +978,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1011,6 +1024,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1064,10 +1080,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 2127cf1fa5..fa7570d178 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking ref\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1892,4 +1913,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"542716","messageId":"20260504182836.52258-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqqfr48rubw.fsf@gitster.g","subject":"[PATCH] checkout: add --autostash option for branch switching","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-04T18:28:36Z","receivedAt":"2026-05-04T18:28:39Z","isPatch":true,"body":"> I do like the feature that allows you to identify which local\n> branches are already merged and prune them.  It will help users keep\n> their local branch namespace clean.\n\nNice to hear!\n\n> To break the feature down to make it easier to use by our users with\n> various needs and workflows, we would benefit from having a\n> collection of smaller features that can be composed, like these:\n\nI gave it a shot to implement these, and then I ran it one some local repos\nit works really nicely!\n\n\nHarald\n"},{"id":"542733","messageId":"2f809a63-3487-449a-a892-225d2b8122b6@app.fastmail.com","threadId":"65574","inReplyTo":"e9f8d06a2bbab155b66e89c467aaeb2f37d808ed.1777919250.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 1/6] branch: add --forked <remote>","fromName":"Kristoffer Haugsbakk","fromEmail":"kristofferhaugsbakk@fastmail.com","sentAt":"2026-05-04T23:25:00Z","receivedAt":"2026-05-04T23:25:21Z","isPatch":true,"body":"On Mon, May 4, 2026, at 20:27, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> List local branches whose configured upstream falls within any of\n> the given <remote> arguments. <remote> may be either a configured\n> remote name (matching all of its remote-tracking refs) or a single\n> remote-tracking ref. Multiple <remote> arguments are unioned.\n>\n> This is the building block for --prune-merged, which deletes the\n> listed branches.\n>\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n\ns/remote-tracking refs/remote-tracking branches/g\n\nHere and below and on the other patches.\n\n> ---\n>  Documentation/git-branch.adoc |  12 ++++\n>  builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n>  t/t3200-branch.sh             |  54 +++++++++++++++++\n>  3 files changed, 174 insertions(+), 2 deletions(-)\n>[snip]\n"},{"id":"542743","messageId":"d8bb745e-56d6-4da5-aac9-7ebcbc0df379@kdbg.org","threadId":"65574","inReplyTo":"xmqqfr48rubw.fsf@gitster.g","subject":"Re: [PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-05T07:14:07Z","receivedAt":"2026-05-05T07:14:24Z","isPatch":true,"body":"Am 04.05.26 um 00:39 schrieb Junio C Hamano:\n> To break the feature down to make it easier to use by our users with\n> various needs and workflows, we would benefit from having a\n> collection of smaller features that can be composed, like these:\n> \n>  * \"git branch --forked <remote>\" lists local branches that build on\n>    something taken from <remote>s.  The option can be given multiple\n>    times to make a union of the results from individual \"--forked\n>    <remote>\".\n\nClearly, this version of --forked does something very different from the\noption `--merged some_branch` that we already have.\n\n> \n>    - <remote> may be a name of a remote, e.g., \"origin\" to mean all\n>      the remote-tracking branches \"refs/remotes/origin/*\", \n> \n>    - <remote> may be \"origin/master\" to name a specific\n>      remote-tracking branch.\n> \n>    - There may be other handy things to cover with <remote>, like\n>      \"--all\" that may act as if you listed all the available\n>      <remote> on the command line.\n> >  * \"git branch --prune-merged <remote>...\" is a short-hand for \"git\n>    branch -d $(git branch --forked <remote>...\".\n\nI don't understand this. The option includes the word \"merged\". Then I\ninterpret the command to prune only branches that have already been\nmerged into something (BTW, merged into what?), but as described, the\ncommand removes all local branches that have been forked from some\n(remote) branch.\n\n> \n>  * \"git fetch/pull --prune-merged <remote>\" can trigger \"git branch\n>    --prune-merged <remote>\" after \"git fetch\" successfully updates\n>    the remote-tracking branches, which should be equivalent to what\n>    you have here..\n\nI think that the intended behavior is to call the equivalent of `git\nbranch --merged X | xargs git branch -d` for a suitable set of 'X' to be\ndetermined by `git fetch`.\n\n-- Hannes\n\n"},{"id":"542744","messageId":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com","subject":"[PATCH v3 0/6] fetch: add fetch.pruneBranches config","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:21Z","receivedAt":"2026-05-05T07:22:30Z","isPatch":true,"body":" * s/remote-tracking refs/remote-tracking branches/g\n\nHarald Nordgren (6):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  fetch: add --prune-merged\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/fetch-options.adoc |   8 +\n Documentation/git-branch.adoc    |  32 ++++\n builtin/branch.c                 | 247 +++++++++++++++++++++++++++++--\n builtin/fetch.c                  |  20 +++\n t/t3200-branch.sh                | 215 +++++++++++++++++++++++++++\n t/t5510-fetch.sh                 |  31 ++++\n 7 files changed, 549 insertions(+), 11 deletions(-)\n\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v3\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v2:\n\n 1:  e9f8d06a2b ! 1:  77e67d4b8b branch: add --forked <remote>\n     @@ Commit message\n      \n          List local branches whose configured upstream falls within any of\n          the given <remote> arguments. <remote> may be either a configured\n     -    remote name (matching all of its remote-tracking refs) or a single\n     -    remote-tracking ref. Multiple <remote> arguments are unioned.\n     +    remote name (matching all of its remote-tracking branches) or a\n     +    single remote-tracking branch. Multiple <remote> arguments are\n     +    unioned.\n      \n          This is the building block for --prune-merged, which deletes the\n          listed branches.\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n      +\tList local branches that fork from any of the given _<remote>_\n      +\targuments, that is, those whose configured upstream\n      +\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n     -+\trefs.\n     ++\tbranches.\n      ++\n      +Each _<remote>_ may be either the name of a configured remote\n      +(e.g. `origin`, meaning any branch tracking a\n     -+`refs/remotes/origin/*` ref) or a specific remote-tracking ref\n     ++`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n      +(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n      +\n       `-v`::\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\t\tfree(full_ref);\n      +\n      +\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n     -+\t\t      \"remote-tracking ref\"), arg);\n     ++\t\t      \"remote-tracking branch\"), arg);\n      +\t}\n      +}\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <remote-tracking-ref> lists only matching branches' '\n     ++test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n      +\tgit -C forked branch --forked origin/one >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\n      +test_expect_success '--forked rejects unknown remote/ref' '\n      +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n     -+\ttest_grep \"neither a configured remote nor a remote-tracking ref\" err\n     ++\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n      +'\n      +\n      +test_expect_success '--forked requires at least one <remote>' '\n 2:  cd4a7e47af = 2:  807c9f981f branch: let delete_branches warn instead of error on bulk refusal\n 3:  c0a5f69eb6 ! 3:  49dc853403 branch: add --prune-merged <remote>\n     @@ Commit message\n      \n          Delete the local branches that --forked <remote> would list,\n          refusing any whose tip is not reachable from its upstream\n     -    remote-tracking ref. With --force, delete unconditionally. The\n     -    currently checked-out branch in any worktree is always preserved.\n     +    remote-tracking branch. With --force, delete unconditionally.\n     +    The currently checked-out branch in any worktree is always\n     +    preserved.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       DESCRIPTION\n       -----------\n      @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote\n     - `refs/remotes/origin/*` ref) or a specific remote-tracking ref\n     + `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n       (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n       \n      +`--prune-merged`::\n      +\tDelete the local branches that `--forked` would list for\n      +\tthe same _<remote>_ arguments, but only when the branch's\n     -+\tpush destination remote-tracking ref (the ref `git push`\n     ++\tpush destination remote-tracking branch (the branch `git push`\n      +\twould update; see `branch_get_push` semantics) no longer\n      +\tresolves locally. In other words: the branch was pushed\n      +\tunder some name on _<remote>_, and that name has since\n      +\tbeen pruned upstream.\n      ++\n      +By default, the local tip must also be reachable from the\n     -+upstream remote-tracking ref (see `--no-merged`); branches with\n     ++upstream remote-tracking branch (see `--no-merged`); branches with\n      +unpushed commits are refused. With `--force` (or `-f`), delete\n      +them regardless. The currently checked-out branch in any worktree\n      +is always preserved.\n 4:  e979fd238b ! 4:  938bf7c794 fetch: add --prune-merged\n     @@ Documentation/fetch-options.adoc: See the PRUNING section below for more details\n      +\tAfter a successful fetch, run `git branch --prune-merged\n      +\t<remote>` for the fetched remote, deleting local branches\n      +\tthat fork from this remote and whose tip is reachable from\n     -+\ttheir upstream remote-tracking ref. See linkgit:git-branch[1]\n     ++\ttheir upstream remote-tracking branch. See linkgit:git-branch[1]\n      +\tfor the exact selection rules. The currently checked-out\n      +\tbranch is always preserved.\n      +\n 5:  0bc5ebbe68 ! 5:  b2e7c97298 branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/git-branch.adoc\n      @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote\n       \tDelete the local branches that `--forked` would list for\n       \tthe same _<remote>_ arguments, but only when the branch's\n     - \tpush destination remote-tracking ref (the ref `git push`\n     + \tpush destination remote-tracking branch (the branch `git push`\n      -\twould update; see `branch_get_push` semantics) no longer\n      -\tresolves locally. In other words: the branch was pushed\n      -\tunder some name on _<remote>_, and that name has since\n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      +\tthat name has since been pruned upstream.\n       +\n      -By default, the local tip must also be reachable from the\n     --upstream remote-tracking ref (see `--no-merged`); branches with\n     +-upstream remote-tracking branch (see `--no-merged`); branches with\n      -unpushed commits are refused. With `--force` (or `-f`), delete\n      -them regardless. The currently checked-out branch in any worktree\n      -is always preserved.\n      +The local tip must also be reachable from the upstream\n     -+remote-tracking ref; branches with unpushed commits are refused.\n     ++remote-tracking branch; branches with unpushed commits are refused.\n      +With `--force` (or `-f`), delete them regardless. The currently\n      +checked-out branch in any worktree is always preserved, as is\n      +any branch with `branch.<name>.pruneMerged` set to `false`.\n 6:  66dac97626 ! 6:  6462642cd0 branch: add --all-remotes flag\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\n      +test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n      +\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n     -+\ttest_grep \"neither a configured remote nor a remote-tracking ref\" err\n     ++\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n      +'\n      +\n      +test_expect_success '--all-remotes alone is rejected' '\n\n-- \ngitgitgadget\n"},{"id":"542745","messageId":"77e67d4b8b7da1b982b384c5ad6044c5637e161f.1777965747.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 1/6] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:22Z","receivedAt":"2026-05-05T07:22:31Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542746","messageId":"807c9f981fa05bd6e06228e54ddacb0a397a0f98.1777965747.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:23Z","receivedAt":"2026-05-05T07:22:33Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"542748","messageId":"49dc8534037937e1a7f73fa06d3dcb66675c9ffe.1777965747.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 3/6] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:24Z","receivedAt":"2026-05-05T07:22:34Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from its upstream\nremote-tracking branch. With --force, delete unconditionally.\nThe currently checked-out branch in any worktree is always\npreserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc | 16 ++++++\n builtin/branch.c              | 97 ++++++++++++++++++++++++++++++-----\n t/t3200-branch.sh             | 81 +++++++++++++++++++++++++++++\n 3 files changed, 182 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..80b20a55eb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking branch (the branch `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+By default, the local tip must also be reachable from the\n+upstream remote-tracking branch (see `--no-merged`); branches with\n+unpushed commits are refused. With `--force` (or `-f`), delete\n+them regardless. The currently checked-out branch in any worktree\n+is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..d3ca320d4d 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -753,36 +754,101 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +891,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +947,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +993,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1003,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -977,6 +1047,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..e6e6eab482 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,85 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/main &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542747","messageId":"938bf7c794631f2dd3429ba338d443bcbb16b9ee.1777965747.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 4/6] fetch: add --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:25Z","receivedAt":"2026-05-05T07:22:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAfter a successful fetch from a configured remote, run\n'git branch --prune-merged <remote>' to delete local branches\nwhose push destination ref has just been pruned.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/fetch-options.adoc |  8 ++++++++\n builtin/fetch.c                  | 20 ++++++++++++++++++++\n t/t5510-fetch.sh                 | 31 +++++++++++++++++++++++++++++++\n 3 files changed, 59 insertions(+)\n\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 81a9d7f9bb..afbd1f60b8 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -185,6 +185,14 @@ See the PRUNING section below for more details.\n +\n See the PRUNING section below for more details.\n \n+`--prune-merged`::\n+\tAfter a successful fetch, run `git branch --prune-merged\n+\t<remote>` for the fetched remote, deleting local branches\n+\tthat fork from this remote and whose tip is reachable from\n+\ttheir upstream remote-tracking branch. See linkgit:git-branch[1]\n+\tfor the exact selection rules. The currently checked-out\n+\tbranch is always preserved.\n+\n endif::git-pull[]\n \n ifndef::git-pull[]\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..5451bf3b5b 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -82,6 +82,8 @@ static int prune = -1; /* unspecified */\n static int prune_tags = -1; /* unspecified */\n #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */\n \n+static int prune_merged;\n+\n static int append, dry_run, force, keep, update_head_ok;\n static int write_fetch_head = 1;\n static int verbosity, deepen_relative, set_upstream, refetch;\n@@ -2189,6 +2191,8 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, prune ? \"--prune\" : \"--no-prune\");\n \tif (prune_tags != -1)\n \t\tstrvec_push(argv, prune_tags ? \"--prune-tags\" : \"--no-prune-tags\");\n+\tif (prune_merged)\n+\t\tstrvec_push(argv, \"--prune-merged\");\n \tif (update_head_ok)\n \t\tstrvec_push(argv, \"--update-head-ok\");\n \tif (force)\n@@ -2382,6 +2386,15 @@ static inline void fetch_one_setup_partial(struct remote *remote,\n \treturn;\n }\n \n+static int prune_merged_for_remote(const struct remote *remote)\n+{\n+\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\n+\tcmd.git_cmd = 1;\n+\tstrvec_pushl(&cmd.args, \"branch\", \"--prune-merged\", remote->name, NULL);\n+\treturn run_command(&cmd);\n+}\n+\n static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t     int prune_tags_ok, int use_stdin_refspecs,\n \t\t     const struct fetch_config *config,\n@@ -2457,6 +2470,11 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \trefspec_clear(&rs);\n \ttransport_disconnect(gtransport);\n \tgtransport = NULL;\n+\n+\tif (!exit_code && prune_merged && remote_via_config &&\n+\t    prune_merged_for_remote(remote))\n+\t\texit_code = 1;\n+\n \treturn exit_code;\n }\n \n@@ -2520,6 +2538,8 @@ int cmd_fetch(int argc,\n \t\t\t N_(\"prune remote-tracking branches no longer on remote\")),\n \t\tOPT_BOOL('P', \"prune-tags\", &prune_tags,\n \t\t\t N_(\"prune local tags no longer on remote and clobber changed tags\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\t N_(\"after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream\")),\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 6fe21e2b3a..b94fd2bda0 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -386,6 +386,37 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '\n \t)\n '\n \n+test_expect_success 'fetch --prune-merged: setup' '\n+\tgit init -b main fetch-pm-parent &&\n+\ttest_commit -C fetch-pm-parent base\n+'\n+\n+test_expect_success 'fetch --prune-merged deletes merged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-clone\" &&\n+\tgit -C fetch-pm-parent branch one base &&\n+\tgit clone fetch-pm-parent fetch-pm-clone &&\n+\tgit -C fetch-pm-clone branch one --track origin/one &&\n+\tgit -C fetch-pm-parent branch -D one &&\n+\n+\tgit -C fetch-pm-clone fetch --prune --prune-merged origin &&\n+\n+\ttest_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'fetch --prune-merged skips unmerged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-unmerged\" &&\n+\tgit -C fetch-pm-parent branch two base &&\n+\tgit clone fetch-pm-parent fetch-pm-unmerged &&\n+\tgit -C fetch-pm-unmerged checkout -b two --track origin/two &&\n+\ttest_commit -C fetch-pm-unmerged unpushed &&\n+\tgit -C fetch-pm-unmerged checkout - &&\n+\tgit -C fetch-pm-parent branch -D two &&\n+\n+\tgit -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\tgit -C fetch-pm-unmerged rev-parse --verify refs/heads/two\n+'\n+\n test_expect_success 'fetch --atomic works with a single branch' '\n \ttest_when_finished \"rm -rf atomic\" &&\n \n-- \ngitgitgadget\n\n"},{"id":"542749","messageId":"b2e7c97298f7628a26a9a27c07cbc6e44646cd7c.1777965748.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:26Z","receivedAt":"2026-05-05T07:22:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 17 +++++++-------\n builtin/branch.c                 | 23 ++++++++++++++++--\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 76 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..60dba38e27 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged` (and `git fetch --prune-merged`).\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 80b20a55eb..9d4944d17e 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,16 +216,15 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking branch (the branch `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n-By default, the local tip must also be reachable from the\n-upstream remote-tracking branch (see `--no-merged`); branches with\n-unpushed commits are refused. With `--force` (or `-f`), delete\n-them regardless. The currently checked-out branch in any worktree\n-is always preserved.\n+The local tip must also be reachable from the upstream\n+remote-tracking branch; branches with unpushed commits are refused.\n+With `--force` (or `-f`), delete them regardless. The currently\n+checked-out branch in any worktree is always preserved, as is\n+any branch with `branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex d3ca320d4d..c2094ca34d 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -809,23 +809,42 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n \n \t\tbranch = branch_get(short_name);\n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e6e6eab482..9af7de690e 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1852,4 +1852,44 @@ test_expect_success '--prune-merged deletes when push ref differs from upstream'\n \ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542750","messageId":"6462642cd040e37ccc90ccc6e3f905cb005eda18.1777965748.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v3 6/6] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T07:22:27Z","receivedAt":"2026-05-05T07:22:38Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 40 ++++++++++++++++++++++++-----------\n t/t3200-branch.sh             | 40 +++++++++++++++++++++++++++++++++++\n 3 files changed, 75 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 9d4944d17e..5c5b91d9b6 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,11 @@ With `--force` (or `-f`), delete them regardless. The currently\n checked-out branch in any worktree is always preserved, as is\n any branch with `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c2094ca34d..78272daa10 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -685,6 +685,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -754,7 +761,7 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n@@ -766,6 +773,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -776,15 +785,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -792,8 +801,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct strvec deletable = STRVEC_INIT;\n@@ -801,10 +810,10 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -911,6 +920,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -968,6 +978,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1011,6 +1024,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1064,10 +1080,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 9af7de690e..f401d8db19 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1892,4 +1913,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"542770","messageId":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com","subject":"[PATCH v4 0/6] fetch: add fetch.pruneBranches config","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:52Z","receivedAt":"2026-05-05T19:24:01Z","isPatch":true,"body":"I ran this on one of my repos and wiped out the local master branch! That\nwas a bad surprise, so I made an update now to treat the default branch in a\nspecial way (never prune it).\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nHarald Nordgren (6):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  fetch: add --prune-merged\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/fetch-options.adoc |   8 +\n Documentation/git-branch.adoc    |  32 ++++\n builtin/branch.c                 | 289 +++++++++++++++++++++++++++++--\n builtin/fetch.c                  |  20 +++\n t/t3200-branch.sh                | 247 ++++++++++++++++++++++++++\n t/t5510-fetch.sh                 |  31 ++++\n 7 files changed, 623 insertions(+), 11 deletions(-)\n\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v4\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v4\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v3:\n\n 1:  77e67d4b8b = 1:  77e67d4b8b branch: add --forked <remote>\n 2:  807c9f981f = 2:  807c9f981f branch: let delete_branches warn instead of error on bulk refusal\n 3:  49dc853403 ! 3:  77beb620d7 branch: add --prune-merged <remote>\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n       }\n       \n      -static int list_forked_branches(int argc, const char **argv)\n     ++static void collect_default_branch_refs(const struct string_list *remote_names,\n     ++\t\t\t\t\tstruct string_list *out)\n     ++{\n     ++\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     ++\tstruct string_list_item *item;\n     ++\n     ++\tfor_each_string_list_item(item, remote_names) {\n     ++\t\tstruct strbuf head = STRBUF_INIT;\n     ++\t\tconst char *target;\n     ++\n     ++\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n     ++\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n     ++\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n     ++\t\t\t\t\t\t NULL, NULL);\n     ++\t\tif (target && starts_with(target, \"refs/remotes/\"))\n     ++\t\t\tstring_list_insert(out, target);\n     ++\t\tstrbuf_release(&head);\n     ++\t}\n     ++}\n     ++\n      +static void collect_forked_set(int argc, const char **argv,\n     ++\t\t\t       struct string_list *protected_default_refs,\n      +\t\t\t       struct string_list *out)\n       {\n       \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      -\tfor_each_string_list_item(item, &out)\n      -\t\tputs(item->string);\n      +\tstring_list_sort(out);\n     ++\n     ++\tif (protected_default_refs)\n     ++\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n       \n       \tstring_list_clear(&remote_names, 0);\n       \tstring_list_clear(&tracking_refs, 0);\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\tif (!argc)\n      +\t\tdie(_(\"--forked requires at least one <remote>\"));\n      +\n     -+\tcollect_forked_set(argc, argv, &out);\n     ++\tcollect_forked_set(argc, argv, NULL, &out);\n      +\tfor_each_string_list_item(item, &out)\n      +\t\tputs(item->string);\n      +\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\t\t\t int quiet)\n      +{\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n      +\tstruct strvec deletable = STRVEC_INIT;\n      +\tstruct string_list_item *item;\n      +\tint n_not_merged = 0;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\tif (!argc)\n      +\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n      +\n     -+\tcollect_forked_set(argc, argv, &candidates);\n     ++\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n      +\n      +\tfor_each_string_list_item(item, &candidates) {\n      +\t\tconst char *short_name = item->string;\n      +\t\tstruct strbuf full = STRBUF_INIT;\n      +\t\tstruct branch *branch;\n      +\t\tconst char *push_ref;\n     ++\t\tconst char *upstream;\n      +\n      +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n      +\t\tif (branch_checked_out(full.buf)) {\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tstrbuf_release(&full);\n      +\n      +\t\tbranch = branch_get(short_name);\n     ++\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     ++\t\tif (upstream &&\n     ++\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n     ++\t\t\tconst char *leaf = strrchr(upstream, '/');\n     ++\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n     ++\t\t\t\tcontinue;\n     ++\t\t}\n     ++\n      +\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n      +\t\tif (!push_ref)\n      +\t\t\tcontinue;\n      +\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n      +\t\t\t\t    push_ref))\n      +\t\t\tcontinue;\n     ++\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n     ++\t\t\tcontinue;\n      +\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\n      +\tstrvec_clear(&deletable);\n      +\tstring_list_clear(&candidates, 0);\n     ++\tstring_list_clear(&protected_default_refs, 0);\n      +\treturn ret;\n      +}\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n      +\tgit clone pm-upstream pm-pushdiff &&\n      +\tgit -C pm-pushdiff config push.default current &&\n     -+\tgit -C pm-pushdiff branch --track topic-a origin/main &&\n     ++\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n      +\n      +\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n      +\n      +\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n      +'\n     ++\n     ++test_expect_success '--prune-merged spares the local default branch' '\n     ++\ttest_when_finished \"rm -rf pm-default\" &&\n     ++\tgit clone pm-upstream pm-default &&\n     ++\tgit -C pm-default config push.default current &&\n     ++\tgit -C pm-default checkout --detach &&\n     ++\tgit -C pm-default branch --prune-merged origin &&\n     ++\tgit -C pm-default rev-parse --verify refs/heads/main\n     ++'\n     ++\n     ++test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n     ++\ttest_when_finished \"rm -rf pm-default-alias\" &&\n     ++\tgit clone pm-upstream pm-default-alias &&\n     ++\tgit -C pm-default-alias config push.default current &&\n     ++\tgit -C pm-default-alias branch --track trunk origin/main &&\n     ++\tgit -C pm-default-alias checkout --detach &&\n     ++\tgit -C pm-default-alias branch --force --prune-merged origin &&\n     ++\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n     ++\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n     ++'\n     ++\n     ++test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n     ++\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n     ++\tgit clone pm-upstream pm-pushdefault &&\n     ++\tgit -C pm-pushdefault branch --track topic origin/one &&\n     ++\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n     ++\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n     ++\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n     ++\tgit -C pm-pushdefault checkout --detach &&\n     ++\tgit -C pm-pushdefault branch --prune-merged origin &&\n     ++\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n     ++'\n      +\n       test_done\n 4:  938bf7c794 = 4:  98cfdb87d2 fetch: add --prune-merged\n 5:  b2e7c97298 ! 5:  c645526bb5 branch: add branch.<name>.pruneMerged opt-out\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\tstruct strbuf key = STRBUF_INIT;\n       \t\tstruct branch *branch;\n       \t\tconst char *push_ref;\n     + \t\tconst char *upstream;\n      +\t\tint opt_out = 0;\n       \n       \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\t\tcontinue;\n       \t\t}\n       \t\tstrbuf_release(&full);\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n     + \t\tif (upstream &&\n     + \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n     + \t\t\tconst char *leaf = strrchr(upstream, '/');\n     +-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n     ++\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n     ++\t\t\t\tstrbuf_release(&key);\n     + \t\t\t\tcontinue;\n     ++\t\t\t}\n     + \t\t}\n       \n     - \t\tbranch = branch_get(short_name);\n       \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n      -\t\tif (!push_ref)\n      +\t\tif (!push_ref) {\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\t\tstrbuf_release(&key);\n      +\t\t\tcontinue;\n      +\t\t}\n     ++\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n     ++\t\t\tstrbuf_release(&key);\n     + \t\t\tcontinue;\n     +-\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n     ++\t\t}\n      +\n      +\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n      +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t}\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged deletes when push ref differs from upstream'\n     - \ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul\n     + \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n       '\n       \n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n 6:  6462642cd0 ! 6:  690242d89b branch: add --all-remotes flag\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n       static void parse_forked_args(int argc, const char **argv,\n       \t\t\t      struct string_list *remote_names,\n       \t\t\t      struct string_list *tracking_refs)\n     -@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n     - \treturn 0;\n     +@@ builtin/branch.c: static void collect_default_branch_refs(const struct string_list *remote_names,\n     + \t}\n       }\n       \n      -static void collect_forked_set(int argc, const char **argv,\n      +static void collect_forked_set(int argc, const char **argv, int all_remotes,\n     + \t\t\t       struct string_list *protected_default_refs,\n       \t\t\t       struct string_list *out)\n       {\n     - \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n      @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n       \t};\n       \n     @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n      +\tif (!argc && !all_remotes)\n      +\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n       \n     --\tcollect_forked_set(argc, argv, &out);\n     -+\tcollect_forked_set(argc, argv, all_remotes, &out);\n     +-\tcollect_forked_set(argc, argv, NULL, &out);\n     ++\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n       \tfor_each_string_list_item(item, &out)\n       \t\tputs(item->string);\n       \n     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n      +\t\t\t\t int all_remotes, int force, int quiet)\n       {\n       \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     - \tstruct strvec deletable = STRVEC_INIT;\n     + \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n       \tint n_not_merged = 0;\n       \tint ret = 0;\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\tif (!argc && !all_remotes)\n      +\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n       \n     --\tcollect_forked_set(argc, argv, &candidates);\n     -+\tcollect_forked_set(argc, argv, all_remotes, &candidates);\n     +-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n     ++\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n     ++\t\t\t   &candidates);\n       \n       \tfor_each_string_list_item(item, &candidates) {\n       \t\tconst char *short_name = item->string;\n\n-- \ngitgitgadget\n"},{"id":"542771","messageId":"77e67d4b8b7da1b982b384c5ad6044c5637e161f.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 1/6] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:53Z","receivedAt":"2026-05-05T19:24:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542772","messageId":"807c9f981fa05bd6e06228e54ddacb0a397a0f98.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:54Z","receivedAt":"2026-05-05T19:24:04Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"542773","messageId":"77beb620d7c5009f1b7511037eee30b8cc3cbe4a.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 3/6] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:55Z","receivedAt":"2026-05-05T19:24:06Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from its upstream\nremote-tracking branch. With --force, delete unconditionally.\nThe currently checked-out branch in any worktree is always\npreserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  16 ++++\n builtin/branch.c              | 134 +++++++++++++++++++++++++++++++---\n t/t3200-branch.sh             | 113 ++++++++++++++++++++++++++++\n 3 files changed, 251 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..80b20a55eb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking branch (the branch `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+By default, the local tip must also be reachable from the\n+upstream remote-tracking branch (see `--no-merged`); branches with\n+unpushed commits are refused. With `--force` (or `-f`), delete\n+them regardless. The currently checked-out branch in any worktree\n+is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..f2ca7b64d3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -753,36 +754,138 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\t\tconst char *upstream;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (upstream &&\n+\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +928,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +984,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1030,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1040,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -977,6 +1084,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..b41f8343b3 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,117 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default config push.default current &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias config push.default current &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --force --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n+\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n+\tgit clone pm-upstream pm-pushdefault &&\n+\tgit -C pm-pushdefault branch --track topic origin/one &&\n+\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n+\tgit -C pm-pushdefault checkout --detach &&\n+\tgit -C pm-pushdefault branch --prune-merged origin &&\n+\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542774","messageId":"98cfdb87d26cec5f91ea4d8ce949512d60958e56.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 4/6] fetch: add --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:56Z","receivedAt":"2026-05-05T19:24:08Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAfter a successful fetch from a configured remote, run\n'git branch --prune-merged <remote>' to delete local branches\nwhose push destination ref has just been pruned.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/fetch-options.adoc |  8 ++++++++\n builtin/fetch.c                  | 20 ++++++++++++++++++++\n t/t5510-fetch.sh                 | 31 +++++++++++++++++++++++++++++++\n 3 files changed, 59 insertions(+)\n\ndiff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc\nindex 81a9d7f9bb..afbd1f60b8 100644\n--- a/Documentation/fetch-options.adoc\n+++ b/Documentation/fetch-options.adoc\n@@ -185,6 +185,14 @@ See the PRUNING section below for more details.\n +\n See the PRUNING section below for more details.\n \n+`--prune-merged`::\n+\tAfter a successful fetch, run `git branch --prune-merged\n+\t<remote>` for the fetched remote, deleting local branches\n+\tthat fork from this remote and whose tip is reachable from\n+\ttheir upstream remote-tracking branch. See linkgit:git-branch[1]\n+\tfor the exact selection rules. The currently checked-out\n+\tbranch is always preserved.\n+\n endif::git-pull[]\n \n ifndef::git-pull[]\ndiff --git a/builtin/fetch.c b/builtin/fetch.c\nindex a22c319467..5451bf3b5b 100644\n--- a/builtin/fetch.c\n+++ b/builtin/fetch.c\n@@ -82,6 +82,8 @@ static int prune = -1; /* unspecified */\n static int prune_tags = -1; /* unspecified */\n #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */\n \n+static int prune_merged;\n+\n static int append, dry_run, force, keep, update_head_ok;\n static int write_fetch_head = 1;\n static int verbosity, deepen_relative, set_upstream, refetch;\n@@ -2189,6 +2191,8 @@ static void add_options_to_argv(struct strvec *argv,\n \t\tstrvec_push(argv, prune ? \"--prune\" : \"--no-prune\");\n \tif (prune_tags != -1)\n \t\tstrvec_push(argv, prune_tags ? \"--prune-tags\" : \"--no-prune-tags\");\n+\tif (prune_merged)\n+\t\tstrvec_push(argv, \"--prune-merged\");\n \tif (update_head_ok)\n \t\tstrvec_push(argv, \"--update-head-ok\");\n \tif (force)\n@@ -2382,6 +2386,15 @@ static inline void fetch_one_setup_partial(struct remote *remote,\n \treturn;\n }\n \n+static int prune_merged_for_remote(const struct remote *remote)\n+{\n+\tstruct child_process cmd = CHILD_PROCESS_INIT;\n+\n+\tcmd.git_cmd = 1;\n+\tstrvec_pushl(&cmd.args, \"branch\", \"--prune-merged\", remote->name, NULL);\n+\treturn run_command(&cmd);\n+}\n+\n static int fetch_one(struct remote *remote, int argc, const char **argv,\n \t\t     int prune_tags_ok, int use_stdin_refspecs,\n \t\t     const struct fetch_config *config,\n@@ -2457,6 +2470,11 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,\n \trefspec_clear(&rs);\n \ttransport_disconnect(gtransport);\n \tgtransport = NULL;\n+\n+\tif (!exit_code && prune_merged && remote_via_config &&\n+\t    prune_merged_for_remote(remote))\n+\t\texit_code = 1;\n+\n \treturn exit_code;\n }\n \n@@ -2520,6 +2538,8 @@ int cmd_fetch(int argc,\n \t\t\t N_(\"prune remote-tracking branches no longer on remote\")),\n \t\tOPT_BOOL('P', \"prune-tags\", &prune_tags,\n \t\t\t N_(\"prune local tags no longer on remote and clobber changed tags\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\t N_(\"after pruning, also delete local branches forked from this remote whose tips are reachable from their upstream\")),\n \t\tOPT_CALLBACK_F(0, \"recurse-submodules\", &recurse_submodules_cli, N_(\"on-demand\"),\n \t\t\t    N_(\"control recursive fetching of submodules\"),\n \t\t\t    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),\ndiff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh\nindex 6fe21e2b3a..b94fd2bda0 100755\n--- a/t/t5510-fetch.sh\n+++ b/t/t5510-fetch.sh\n@@ -386,6 +386,37 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '\n \t)\n '\n \n+test_expect_success 'fetch --prune-merged: setup' '\n+\tgit init -b main fetch-pm-parent &&\n+\ttest_commit -C fetch-pm-parent base\n+'\n+\n+test_expect_success 'fetch --prune-merged deletes merged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-clone\" &&\n+\tgit -C fetch-pm-parent branch one base &&\n+\tgit clone fetch-pm-parent fetch-pm-clone &&\n+\tgit -C fetch-pm-clone branch one --track origin/one &&\n+\tgit -C fetch-pm-parent branch -D one &&\n+\n+\tgit -C fetch-pm-clone fetch --prune --prune-merged origin &&\n+\n+\ttest_must_fail git -C fetch-pm-clone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'fetch --prune-merged skips unmerged local branches' '\n+\ttest_when_finished \"rm -rf fetch-pm-unmerged\" &&\n+\tgit -C fetch-pm-parent branch two base &&\n+\tgit clone fetch-pm-parent fetch-pm-unmerged &&\n+\tgit -C fetch-pm-unmerged checkout -b two --track origin/two &&\n+\ttest_commit -C fetch-pm-unmerged unpushed &&\n+\tgit -C fetch-pm-unmerged checkout - &&\n+\tgit -C fetch-pm-parent branch -D two &&\n+\n+\tgit -C fetch-pm-unmerged fetch --prune --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\tgit -C fetch-pm-unmerged rev-parse --verify refs/heads/two\n+'\n+\n test_expect_success 'fetch --atomic works with a single branch' '\n \ttest_when_finished \"rm -rf atomic\" &&\n \n-- \ngitgitgadget\n\n"},{"id":"542775","messageId":"c645526bb5c699df5238d148e8fd8d2b891cd337.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:57Z","receivedAt":"2026-05-05T19:24:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 17 +++++++-------\n builtin/branch.c                 | 31 +++++++++++++++++++++----\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 82 insertions(+), 13 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..60dba38e27 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged` (and `git fetch --prune-merged`).\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 80b20a55eb..9d4944d17e 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,16 +216,15 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking branch (the branch `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n-By default, the local tip must also be reachable from the\n-upstream remote-tracking branch (see `--no-merged`); branches with\n-unpushed commits are refused. With `--force` (or `-f`), delete\n-them regardless. The currently checked-out branch in any worktree\n-is always preserved.\n+The local tip must also be reachable from the upstream\n+remote-tracking branch; branches with unpushed commits are refused.\n+With `--force` (or `-f`), delete them regardless. The currently\n+checked-out branch in any worktree is always preserved, as is\n+any branch with `branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f2ca7b64d3..07d867373f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -834,13 +834,16 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n \t\tconst char *upstream;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -850,18 +853,38 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \t\tif (upstream &&\n \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n \t\t}\n \n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n-\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b41f8343b3..f9aca90f4d 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1884,4 +1884,44 @@ test_expect_success '--prune-merged spares branches whose push ref is the defaul\n \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"542776","messageId":"690242d89bdac6e04dbb7d00133193f8006c6f49.1778009038.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 6/6] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-05T19:23:58Z","receivedAt":"2026-05-05T19:24:11Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 41 +++++++++++++++++++++++++----------\n t/t3200-branch.sh             | 40 ++++++++++++++++++++++++++++++++++\n 3 files changed, 76 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 9d4944d17e..5c5b91d9b6 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,11 @@ With `--force` (or `-f`), delete them regardless. The currently\n checked-out branch in any worktree is always preserved, as is\n any branch with `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 07d867373f..37ea75ecc3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -685,6 +685,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -774,7 +781,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -787,6 +794,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -800,15 +809,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -816,8 +825,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -826,10 +835,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -952,6 +962,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1009,6 +1020,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1052,6 +1066,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1105,10 +1122,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex f9aca90f4d..3809bfe0ad 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1924,4 +1945,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"542786","messageId":"1e38fb35-f75d-4067-856e-b5c15f507007@kdbg.org","threadId":"65574","inReplyTo":"98cfdb87d26cec5f91ea4d8ce949512d60958e56.1778009038.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v4 4/6] fetch: add --prune-merged","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-05T20:48:53Z","receivedAt":"2026-05-05T20:49:04Z","isPatch":true,"body":"Am 05.05.26 um 21:23 schrieb Harald Nordgren via GitGitGadget:\n> After a successful fetch from a configured remote, run\n> 'git branch --prune-merged <remote>' to delete local branches\n> whose push destination ref has just been pruned.\n\nI have some sympathy for the desire to clean up unnecessary local\nbranches, but I don't like the concept that `git fetch` modifies local\nbranches, not even as an opt-in. Deleting local branches should be `git\nbranch`'s task exclusively (at the porcelain level).\n\n-- Hannes\n\n"},{"id":"542790","messageId":"20260505220712.93952-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"1e38fb35-f75d-4067-856e-b5c15f507007@kdbg.org","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-05T22:07:12Z","receivedAt":"2026-05-05T22:07:16Z","isPatch":true,"body":"> I have some sympathy for the desire to clean up unnecessary local\n> branches, but I don't like the concept that `git fetch` modifies local\n> branches, not even as an opt-in. Deleting local branches should be `git\n> branch`'s task exclusively (at the porcelain level).\n\nYeah, maybe that's a good point.\n\n\nHarald\n"},{"id":"542877","messageId":"20260507201446.41771-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v4 0/6] fetch: add fetch.pruneBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-07T20:14:46Z","receivedAt":"2026-05-07T20:14:49Z","isPatch":true,"body":"Can I get some more feedback here? I have used to on all of my repos and\nit helped me clean up a lot of branches.\n\n\nHarald\n"},{"id":"542959","messageId":"xmqqzf289ixw.fsf@gitster.g","threadId":"65574","inReplyTo":"20260504182836.52258-1-haraldnordgren@gmail.com","subject":"Re: [PATCH] checkout: add --autostash option for branch switching","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-10T01:01:15Z","receivedAt":"2026-05-10T01:01:17Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n>> I do like the feature that allows you to identify which local\n>> branches are already merged and prune them.  It will help users keep\n>> their local branch namespace clean.\n>\n> Nice to hear!\n>\n>> To break the feature down to make it easier to use by our users with\n>> various needs and workflows, we would benefit from having a\n>> collection of smaller features that can be composed, like these:\n>\n> I gave it a shot to implement these, and then I ran it one some local repos\n> it works really nicely!\n>\n>\n> Harald\n\nIt was baffling to see a message with the subject \"checkout: add\n--autostash\" as your reponse to my message that was a response to\n\"fetch: add fetch.pruneLocalBranches\".\n\n"},{"id":"543005","messageId":"xmqqh5oetzw3.fsf@gitster.g","threadId":"65574","inReplyTo":"20260505220712.93952-1-haraldnordgren@gmail.com","subject":"Re: [PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-11T02:59:24Z","receivedAt":"2026-05-11T02:59:26Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n>> I have some sympathy for the desire to clean up unnecessary local\n>> branches, but I don't like the concept that `git fetch` modifies local\n>> branches, not even as an opt-in. Deleting local branches should be `git\n>> branch`'s task exclusively (at the porcelain level).\n>\n> Yeah, maybe that's a good point.\n\nI think the latest iteration was sent after the above exchange, yet\nit seems to have changes to builtin/fetch.c to cause `git fetch` to\nmodify local branches still.  Will we have another update that is\nhopefully final to excise that part, or are we OK to allow `fetch`\nto modify the local state as an opt-in now?\n\nThanks.\n"},{"id":"543016","messageId":"20260511065612.43515-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqqh5oetzw3.fsf@gitster.g","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-11T06:56:12Z","receivedAt":"2026-05-11T06:56:16Z","isPatch":true,"body":">>> I have some sympathy for the desire to clean up unnecessary local\n>>> branches, but I don't like the concept that `git fetch` modifies local\n>>> branches, not even as an opt-in. Deleting local branches should be `git\n>>> branch`'s task exclusively (at the porcelain level).\n>>\n>> Yeah, maybe that's a good point.\n>\n> I think the latest iteration was sent after the above exchange, yet\n> it seems to have changes to builtin/fetch.c to cause `git fetch` to\n> modify local branches still.  Will we have another update that is\n> hopefully final to excise that part, or are we OK to allow `fetch`\n> to modify the local state as an opt-in now?\n\nDone! (I didn't know if we wanted to do this yet, or we still just\ndiscussion it, but now I deleted it.)\n\n\nHarald\n"},{"id":"543017","messageId":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com","subject":"[PATCH v5 0/5] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:23Z","receivedAt":"2026-05-11T06:58:31Z","isPatch":true,"body":"Drop commit 'fetch: add --prune-merged'\n\nHarald Nordgren (5):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  32 ++++\n builtin/branch.c                 | 289 +++++++++++++++++++++++++++++--\n t/t3200-branch.sh                | 247 ++++++++++++++++++++++++++\n 4 files changed, 564 insertions(+), 11 deletions(-)\n\n\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v5\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v5\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v4:\n\n 1:  77e67d4b8b = 1:  77e67d4b8b branch: add --forked <remote>\n 2:  807c9f981f = 2:  807c9f981f branch: let delete_branches warn instead of error on bulk refusal\n 3:  77beb620d7 = 3:  77beb620d7 branch: add --prune-merged <remote>\n 4:  98cfdb87d2 < -:  ---------- fetch: add --prune-merged\n 5:  c645526bb5 ! 4:  cf69fb5767 branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/config/branch.adoc: for details).\n      +\n      +`branch.<name>.pruneMerged`::\n      +\tIf set to `false`, branch _<name>_ is exempt from\n     -+\t`git branch --prune-merged` (and `git fetch --prune-merged`).\n     ++\t`git branch --prune-merged`.\n      +\tUseful for topic branches you intend to develop further after\n      +\tan initial round has been merged upstream. Defaults to true.\n      +\tExplicit deletion via `git branch -d` is unaffected.\n 6:  690242d89b = 5:  f2cee8c79b branch: add --all-remotes flag\n\n-- \ngitgitgadget\n"},{"id":"543018","messageId":"77e67d4b8b7da1b982b384c5ad6044c5637e161f.1778482708.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v5 1/5] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:24Z","receivedAt":"2026-05-11T06:58:33Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543019","messageId":"807c9f981fa05bd6e06228e54ddacb0a397a0f98.1778482708.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v5 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:25Z","receivedAt":"2026-05-11T06:58:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543020","messageId":"77beb620d7c5009f1b7511037eee30b8cc3cbe4a.1778482708.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v5 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:26Z","receivedAt":"2026-05-11T06:58:36Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from its upstream\nremote-tracking branch. With --force, delete unconditionally.\nThe currently checked-out branch in any worktree is always\npreserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  16 ++++\n builtin/branch.c              | 134 +++++++++++++++++++++++++++++++---\n t/t3200-branch.sh             | 113 ++++++++++++++++++++++++++++\n 3 files changed, 251 insertions(+), 12 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..80b20a55eb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking branch (the branch `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+By default, the local tip must also be reachable from the\n+upstream remote-tracking branch (see `--no-merged`); branches with\n+unpushed commits are refused. With `--force` (or `-f`), delete\n+them regardless. The currently checked-out branch in any worktree\n+is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..f2ca7b64d3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -753,36 +754,138 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\t\tconst char *upstream;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (upstream &&\n+\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +928,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +984,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1030,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1040,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -977,6 +1084,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..b41f8343b3 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,117 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default config push.default current &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias config push.default current &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --force --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n+\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n+\tgit clone pm-upstream pm-pushdefault &&\n+\tgit -C pm-pushdefault branch --track topic origin/one &&\n+\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n+\tgit -C pm-pushdefault checkout --detach &&\n+\tgit -C pm-pushdefault branch --prune-merged origin &&\n+\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543021","messageId":"cf69fb5767645f7e0b0ad83d97468d5e55d834f0.1778482708.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v5 4/5] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:27Z","receivedAt":"2026-05-11T06:58:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 17 +++++++-------\n builtin/branch.c                 | 31 +++++++++++++++++++++----\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 82 insertions(+), 13 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..4662ef35c1 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 80b20a55eb..9d4944d17e 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,16 +216,15 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking branch (the branch `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n-By default, the local tip must also be reachable from the\n-upstream remote-tracking branch (see `--no-merged`); branches with\n-unpushed commits are refused. With `--force` (or `-f`), delete\n-them regardless. The currently checked-out branch in any worktree\n-is always preserved.\n+The local tip must also be reachable from the upstream\n+remote-tracking branch; branches with unpushed commits are refused.\n+With `--force` (or `-f`), delete them regardless. The currently\n+checked-out branch in any worktree is always preserved, as is\n+any branch with `branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f2ca7b64d3..07d867373f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -834,13 +834,16 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n \t\tconst char *upstream;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -850,18 +853,38 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \t\tif (upstream &&\n \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n \t\t}\n \n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n-\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b41f8343b3..f9aca90f4d 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1884,4 +1884,44 @@ test_expect_success '--prune-merged spares branches whose push ref is the defaul\n \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543022","messageId":"f2cee8c79b1ac3111410309c262283c645a1aae6.1778482708.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v5 5/5] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T06:58:28Z","receivedAt":"2026-05-11T06:58:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 41 +++++++++++++++++++++++++----------\n t/t3200-branch.sh             | 40 ++++++++++++++++++++++++++++++++++\n 3 files changed, 76 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 9d4944d17e..5c5b91d9b6 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,11 @@ With `--force` (or `-f`), delete them regardless. The currently\n checked-out branch in any worktree is always preserved, as is\n any branch with `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 07d867373f..37ea75ecc3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -685,6 +685,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -774,7 +781,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -787,6 +794,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -800,15 +809,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -816,8 +825,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -826,10 +835,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -952,6 +962,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1009,6 +1020,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1052,6 +1066,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1105,10 +1122,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex f9aca90f4d..3809bfe0ad 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1924,4 +1945,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543029","messageId":"xmqq7bpas6k0.fsf@gitster.g","threadId":"65574","inReplyTo":"807c9f981fa05bd6e06228e54ddacb0a397a0f98.1778482708.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-11T08:18:23Z","receivedAt":"2026-05-11T08:18:30Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Add two new parameters to delete_branches() and the helper\n> check_branch_commit():\n>\n> * warn_only switches the per-branch refusal from a hard error\n>   (\"error: the branch 'X' is not fully merged\" plus a four-line\n>   hint about 'git branch -D X') to a one-line warning, and\n>   causes the function to skip those branches without setting its\n>   exit code. Each refused branch is still skipped from deletion.\n> * n_not_merged, when non-NULL, is incremented for each branch\n>   refused on the not-merged path, so a bulk caller can summarize\n>   rather than print per-branch advice.\n>\n> All existing call sites pass 0 / NULL and so are unaffected. Both\n> parameters are wired up so a bulk-deletion caller can suppress\n> the noise normally appropriate for a one-shot 'git branch -d'.\n\nExisting call sites are about \"branch -d <other>\" that allows the\nother branch to be deleted if it is part of HEAD or if it is part of\nits tracking branch, but should \"branch --prune-merged\" pay\nattention to what branch happens to be checked out the same way (not\na rherotical question to hint that I do not think it should---I do\nnot have a strong opinion on this either way)?\n\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>  builtin/branch.c | 29 ++++++++++++++++++++---------\n>  1 file changed, 20 insertions(+), 9 deletions(-)\n>\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index b3289a8875..1941f8a9ad 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n>  \n>  static int check_branch_commit(const char *branchname, const char *refname,\n>  \t\t\t       const struct object_id *oid, struct commit *head_rev,\n> -\t\t\t       int kinds, int force)\n> +\t\t\t       int kinds, int force, int warn_only,\n> +\t\t\t       int *n_not_merged)\n>  {\n>  \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n>  \tif (!force && !rev) {\n> @@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n>  \t\treturn -1;\n>  \t}\n>  \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n> -\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n> -\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> -\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> -\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\tif (warn_only) {\n> +\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t\tbranchname);\n> +\t\t} else {\n> +\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t      branchname);\n> +\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> +\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> +\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\t}\n> +\t\tif (n_not_merged)\n> +\t\t\t(*n_not_merged)++;\n>  \t\treturn -1;\n>  \t}\n>  \treturn 0;\n> @@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n>  }\n>  \n>  static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet)\n> +\t\t\t   int quiet, int warn_only, int *n_not_merged)\n>  {\n>  \tstruct commit *head_rev = NULL;\n>  \tstruct object_id oid;\n> @@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>  \n>  \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>  \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n> -\t\t\t\t\tforce)) {\n> -\t\t\tret = 1;\n> +\t\t\t\t\tforce, warn_only, n_not_merged)) {\n> +\t\t\tif (!warn_only)\n> +\t\t\t\tret = 1;\n>  \t\t\tgoto next;\n>  \t\t}\n>  \n> @@ -961,7 +971,8 @@ int cmd_branch(int argc,\n>  \tif (delete) {\n>  \t\tif (!argc)\n>  \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n> +\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n> +\t\t\t\t      quiet, 0, NULL);\n>  \t\tgoto out;\n>  \t} else if (forked) {\n>  \t\tret = list_forked_branches(argc, argv);\n"},{"id":"543033","messageId":"20260511084436.76336-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqq7bpas6k0.fsf@gitster.g","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-11T08:44:36Z","receivedAt":"2026-05-11T08:44:40Z","isPatch":true,"body":"> Existing call sites are about \"branch -d <other>\" that allows the\n> other branch to be deleted if it is part of HEAD or if it is part of\n> its tracking branch, but should \"branch --prune-merged\" pay\n> attention to what branch happens to be checked out the same way (not\n> a rherotical question to hint that I do not think it should---I do\n> not have a strong opinion on this either way)?\n\nThis is a very good question! My opion is that it should work the same way\nregardless of which branch you are on, it should always compare against the\nremote's default branch.\n\nI this explains some weirdness I saw today when running it from non-main\nand prune didn't get triggered.\n\nI will look into making that change.\n\n\nHarald\n"},{"id":"543035","messageId":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v5.git.git.1778482708.gitgitgadget@gmail.com","subject":"[PATCH v6 0/5] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:46Z","receivedAt":"2026-05-11T09:44:55Z","isPatch":true,"body":" * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nHarald Nordgren (5):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  32 +++\n builtin/branch.c                 | 344 +++++++++++++++++++++++++++++--\n t/t3200-branch.sh                | 278 +++++++++++++++++++++++++\n 4 files changed, 647 insertions(+), 14 deletions(-)\n\n\nbase-commit: 7760f83b59750c27df653c5c46d0f80e44cfe02c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v6\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v6\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v5:\n\n 1:  77e67d4b8b = 1:  fb9817b220 branch: add --forked <remote>\n 2:  807c9f981f = 2:  42a2f93d44 branch: let delete_branches warn instead of error on bulk refusal\n 3:  77beb620d7 ! 3:  604ecb8965 branch: add --prune-merged <remote>\n     @@ Commit message\n          branch: add --prune-merged <remote>\n      \n          Delete the local branches that --forked <remote> would list,\n     -    refusing any whose tip is not reachable from its upstream\n     -    remote-tracking branch. With --force, delete unconditionally.\n     -    The currently checked-out branch in any worktree is always\n     -    preserved.\n     +    refusing any whose tip is not reachable from the remote's default\n     +    branch. With --force, delete unconditionally. The currently\n     +    checked-out branch in any worktree is always preserved.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      +\tunder some name on _<remote>_, and that name has since\n      +\tbeen pruned upstream.\n      ++\n     -+By default, the local tip must also be reachable from the\n     -+upstream remote-tracking branch (see `--no-merged`); branches with\n     -+unpushed commits are refused. With `--force` (or `-f`), delete\n     -+them regardless. The currently checked-out branch in any worktree\n     -+is always preserved.\n     ++As a safety check, branches with commits not yet integrated into\n     ++the remote's default branch are refused. With `--force` (or `-f`),\n     ++delete them regardless. The currently checked-out branch in any\n     ++worktree is always preserved.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c\n       #include \"column.h\"\n       #include \"utf8.h\"\n       #include \"ref-filter.h\"\n     +@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     + \n     + static int check_branch_commit(const char *branchname, const char *refname,\n     + \t\t\t       const struct object_id *oid, struct commit *head_rev,\n     ++\t\t\t       struct commit *head_rev_override,\n     ++\t\t\t       int use_head_rev_override,\n     + \t\t\t       int kinds, int force, int warn_only,\n     + \t\t\t       int *n_not_merged)\n     + {\n     + \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     ++\tint merged;\n     ++\n     + \tif (!force && !rev) {\n     + \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n     + \t\treturn -1;\n     + \t}\n     +-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     ++\tif (use_head_rev_override) {\n     ++\t\tif (!head_rev_override)\n     ++\t\t\treturn 0;\n     ++\t\tmerged = repo_in_merge_bases(the_repository, rev,\n     ++\t\t\t\t\t     head_rev_override);\n     ++\t\tif (merged < 0)\n     ++\t\t\texit(128);\n     ++\t} else {\n     ++\t\tmerged = branch_merged(kinds, branchname, rev, head_rev);\n     ++\t}\n     ++\tif (!force && !merged) {\n     + \t\tif (warn_only) {\n     + \t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n     + \t\t\t\tbranchname);\n     +@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     + \tstrbuf_release(&buf);\n     + }\n     + \n     +-static int delete_branches(int argc, const char **argv, int force, int kinds,\n     ++static int delete_branches(int argc, const char **argv,\n     ++\t\t\t   struct commit **head_rev_overrides,\n     ++\t\t\t   int force, int kinds,\n     + \t\t\t   int quiet, int warn_only, int *n_not_merged)\n     + {\n     + \tstruct commit *head_rev = NULL;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\t}\n     + \n     + \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     +-\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n     +-\t\t\t\t\tforce, warn_only, n_not_merged)) {\n     ++\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n     ++\t\t\t\t\thead_rev_overrides ? head_rev_overrides[i] : NULL,\n     ++\t\t\t\t\t!!head_rev_overrides,\n     ++\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n     + \t\t\tif (!warn_only)\n     + \t\t\t\tret = 1;\n     + \t\t\tgoto next;\n      @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n       \treturn 0;\n       }\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n       \treturn 0;\n       }\n       \n     ++static struct commit *resolve_remote_head(const char *remote_name)\n     ++{\n     ++\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     ++\tstruct strbuf head_ref = STRBUF_INIT;\n     ++\tstruct object_id oid;\n     ++\tstruct commit *commit = NULL;\n     ++\n     ++\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote_name);\n     ++\tif (refs_resolve_ref_unsafe(refs, head_ref.buf, RESOLVE_REF_READING,\n     ++\t\t\t\t    &oid, NULL))\n     ++\t\tcommit = lookup_commit_reference(the_repository, &oid);\n     ++\tstrbuf_release(&head_ref);\n     ++\treturn commit;\n     ++}\n     ++\n      +static int prune_merged_branches(int argc, const char **argv, int force,\n      +\t\t\t\t int quiet)\n      +{\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n      +\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n      +\tstruct strvec deletable = STRVEC_INIT;\n     ++\tstruct commit **head_rev_overrides = NULL;\n     ++\tsize_t alloc = 0;\n      +\tstruct string_list_item *item;\n      +\tint n_not_merged = 0;\n      +\tint ret = 0;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tstruct branch *branch;\n      +\t\tconst char *push_ref;\n      +\t\tconst char *upstream;\n     ++\t\tconst char *remote_name;\n     ++\t\tconst char *slash;\n      +\n      +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n      +\t\tif (branch_checked_out(full.buf)) {\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n      +\t\t\tcontinue;\n      +\n     ++\t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n     ++\t\tremote_name = push_ref + strlen(\"refs/remotes/\");\n     ++\t\tslash = strchr(remote_name, '/');\n     ++\t\tif (slash) {\n     ++\t\t\tchar *name = xstrndup(remote_name, slash - remote_name);\n     ++\t\t\thead_rev_overrides[deletable.nr] = resolve_remote_head(name);\n     ++\t\t\tfree(name);\n     ++\t\t} else {\n     ++\t\t\thead_rev_overrides[deletable.nr] = NULL;\n     ++\t\t}\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n      +\n      +\tif (deletable.nr)\n     -+\t\tret = delete_branches(deletable.nr, deletable.v, force,\n     ++\t\tret = delete_branches(deletable.nr, deletable.v,\n     ++\t\t\t\t      head_rev_overrides, force,\n      +\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n      +\t\t\t\t      1, &n_not_merged);\n      +\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\t\tn_not_merged);\n      +\n      +\tstrvec_clear(&deletable);\n     ++\tfree(head_rev_overrides);\n      +\tstring_list_clear(&candidates, 0);\n      +\tstring_list_clear(&protected_default_refs, 0);\n      +\treturn ret;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     + \tif (delete) {\n     + \t\tif (!argc)\n     + \t\t\tdie(_(\"branch name required\"));\n     +-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     ++\t\tret = delete_branches(argc, argv, NULL, delete > 1, filter.kind,\n     + \t\t\t\t      quiet, 0, NULL);\n     + \t\tgoto out;\n       \t} else if (forked) {\n       \t\tret = list_forked_branches(argc, argv);\n       \t\tgoto out;\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n      +'\n      +\n     ++test_expect_success '--prune-merged measures merged-ness against <remote>/HEAD, not local HEAD' '\n     ++\ttest_when_finished \"rm -rf pm-head-indep\" &&\n     ++\tgit clone pm-upstream pm-head-indep &&\n     ++\tgit -C pm-head-indep branch one --track origin/one &&\n     ++\tgit -C pm-head-indep update-ref -d refs/remotes/origin/one &&\n     ++\t# Detach HEAD to an unrelated commit so the candidate is not\n     ++\t# reachable from local HEAD; it is still reachable from\n     ++\t# refs/remotes/origin/HEAD, which is what should matter.\n     ++\tgit -C pm-head-indep commit --allow-empty -m unrelated &&\n     ++\tgit -C pm-head-indep checkout --detach &&\n     ++\tgit -C pm-head-indep reset --hard HEAD^ &&\n     ++\n     ++\tgit -C pm-head-indep branch --prune-merged origin &&\n     ++\n     ++\ttest_must_fail git -C pm-head-indep rev-parse --verify refs/heads/one\n     ++'\n     ++\n     ++test_expect_success '--prune-merged skips merged-ness check when <remote>/HEAD is unset' '\n     ++\ttest_when_finished \"rm -rf pm-no-head\" &&\n     ++\tgit clone pm-upstream pm-no-head &&\n     ++\tgit -C pm-no-head checkout -b one --track origin/one &&\n     ++\ttest_commit -C pm-no-head unpushed &&\n     ++\tgit -C pm-no-head checkout - &&\n     ++\n     ++\tgit -C pm-no-head update-ref -d refs/remotes/origin/HEAD &&\n     ++\tgit -C pm-no-head update-ref -d refs/remotes/origin/one &&\n     ++\tgit -C pm-no-head branch --prune-merged origin &&\n     ++\n     ++\ttest_must_fail git -C pm-no-head rev-parse --verify refs/heads/one\n     ++'\n     ++\n      +test_expect_success '--prune-merged never deletes the checked-out branch' '\n      +\ttest_when_finished \"rm -rf pm-head\" &&\n      +\tgit clone pm-upstream pm-head &&\n 4:  cf69fb5767 ! 4:  717fc6758e branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      +\tthe branch was pushed under some name on _<remote>_, and\n      +\tthat name has since been pruned upstream.\n       +\n     --By default, the local tip must also be reachable from the\n     --upstream remote-tracking branch (see `--no-merged`); branches with\n     --unpushed commits are refused. With `--force` (or `-f`), delete\n     --them regardless. The currently checked-out branch in any worktree\n     --is always preserved.\n     -+The local tip must also be reachable from the upstream\n     -+remote-tracking branch; branches with unpushed commits are refused.\n     -+With `--force` (or `-f`), delete them regardless. The currently\n     -+checked-out branch in any worktree is always preserved, as is\n     -+any branch with `branch.<name>.pruneMerged` set to `false`.\n     + As a safety check, branches with commits not yet integrated into\n     + the remote's default branch are refused. With `--force` (or `-f`),\n     + delete them regardless. The currently checked-out branch in any\n     +-worktree is always preserved.\n     ++worktree is always preserved, as is any branch with\n     ++`branch.<name>.pruneMerged` set to `false`.\n       \n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\tstruct branch *branch;\n       \t\tconst char *push_ref;\n       \t\tconst char *upstream;\n     + \t\tconst char *remote_name;\n     + \t\tconst char *slash;\n      +\t\tint opt_out = 0;\n       \n       \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\t}\n      +\t\tstrbuf_release(&key);\n       \n     - \t\tstrvec_push(&deletable, short_name);\n     - \t}\n     + \t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n     + \t\tremote_name = push_ref + strlen(\"refs/remotes/\");\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul\n 5:  f2cee8c79b ! 5:  be25572957 branch: add --all-remotes flag\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete them regardless. The currently\n     - checked-out branch in any worktree is always preserved, as is\n     - any branch with `branch.<name>.pruneMerged` set to `false`.\n     +@@ Documentation/git-branch.adoc: delete them regardless. The currently checked-out branch in any\n     + worktree is always preserved, as is any branch with\n     + `branch.<name>.pruneMerged` set to `false`.\n       \n      +`--all-remotes`::\n      +\tWith `--forked` or `--prune-merged`, act on every\n     @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n       \tfor_each_string_list_item(item, &out)\n       \t\tputs(item->string);\n       \n     -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n     - \treturn 0;\n     +@@ builtin/branch.c: static struct commit *resolve_remote_head(const char *remote_name)\n     + \treturn commit;\n       }\n       \n      -static int prune_merged_branches(int argc, const char **argv, int force,\n\n-- \ngitgitgadget\n"},{"id":"543036","messageId":"fb9817b220f3b5a55a507decf85baee38d430f3f.1778492691.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v6 1/5] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:47Z","receivedAt":"2026-05-11T09:44:57Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543037","messageId":"42a2f93d4405a9c89c3bfea3cf4f4fd4b1ad1e4d.1778492691.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v6 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:48Z","receivedAt":"2026-05-11T09:44:58Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543038","messageId":"604ecb89650d87a9dbcf78e1c537ce8dc8c8995c.1778492691.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v6 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:49Z","receivedAt":"2026-05-11T09:45:00Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from the remote's default\nbranch. With --force, delete unconditionally. The currently\nchecked-out branch in any worktree is always preserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  15 +++\n builtin/branch.c              | 193 +++++++++++++++++++++++++++++++---\n t/t3200-branch.sh             | 144 +++++++++++++++++++++++++\n 3 files changed, 335 insertions(+), 17 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..a5e869270d 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,20 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking branch (the branch `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+As a safety check, branches with commits not yet integrated into\n+the remote's default branch are refused. With `--force` (or `-f`),\n+delete them regardless. The currently checked-out branch in any\n+worktree is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..d2f07cddd8 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -192,15 +193,29 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n+\t\t\t       struct commit *head_rev_override,\n+\t\t\t       int use_head_rev_override,\n \t\t\t       int kinds, int force, int warn_only,\n \t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n+\tint merged;\n+\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (use_head_rev_override) {\n+\t\tif (!head_rev_override)\n+\t\t\treturn 0;\n+\t\tmerged = repo_in_merge_bases(the_repository, rev,\n+\t\t\t\t\t     head_rev_override);\n+\t\tif (merged < 0)\n+\t\t\texit(128);\n+\t} else {\n+\t\tmerged = branch_merged(kinds, branchname, rev, head_rev);\n+\t}\n+\tif (!force && !merged) {\n \t\tif (warn_only) {\n \t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n \t\t\t\tbranchname);\n@@ -227,7 +242,9 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n+static int delete_branches(int argc, const char **argv,\n+\t\t\t   struct commit **head_rev_overrides,\n+\t\t\t   int force, int kinds,\n \t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n@@ -317,8 +334,10 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n-\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n+\t\t\t\t\thead_rev_overrides ? head_rev_overrides[i] : NULL,\n+\t\t\t\t\t!!head_rev_overrides,\n+\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n \t\t\tif (!warn_only)\n \t\t\t\tret = 1;\n \t\t\tgoto next;\n@@ -753,36 +772,169 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static struct commit *resolve_remote_head(const char *remote_name)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct strbuf head_ref = STRBUF_INIT;\n+\tstruct object_id oid;\n+\tstruct commit *commit = NULL;\n+\n+\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote_name);\n+\tif (refs_resolve_ref_unsafe(refs, head_ref.buf, RESOLVE_REF_READING,\n+\t\t\t\t    &oid, NULL))\n+\t\tcommit = lookup_commit_reference(the_repository, &oid);\n+\tstrbuf_release(&head_ref);\n+\treturn commit;\n+}\n+\n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct commit **head_rev_overrides = NULL;\n+\tsize_t alloc = 0;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\t\tconst char *upstream;\n+\t\tconst char *remote_name;\n+\t\tconst char *slash;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (upstream &&\n+\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t\tcontinue;\n+\n+\t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n+\t\tremote_name = push_ref + strlen(\"refs/remotes/\");\n+\t\tslash = strchr(remote_name, '/');\n+\t\tif (slash) {\n+\t\t\tchar *name = xstrndup(remote_name, slash - remote_name);\n+\t\t\thead_rev_overrides[deletable.nr] = resolve_remote_head(name);\n+\t\t\tfree(name);\n+\t\t} else {\n+\t\t\thead_rev_overrides[deletable.nr] = NULL;\n+\t\t}\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      head_rev_overrides, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tfree(head_rev_overrides);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +977,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +1033,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1079,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1089,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -971,12 +1127,15 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\tret = delete_branches(argc, argv, NULL, delete > 1, filter.kind,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..ca3d06a1ec 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,148 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged measures merged-ness against <remote>/HEAD, not local HEAD' '\n+\ttest_when_finished \"rm -rf pm-head-indep\" &&\n+\tgit clone pm-upstream pm-head-indep &&\n+\tgit -C pm-head-indep branch one --track origin/one &&\n+\tgit -C pm-head-indep update-ref -d refs/remotes/origin/one &&\n+\t# Detach HEAD to an unrelated commit so the candidate is not\n+\t# reachable from local HEAD; it is still reachable from\n+\t# refs/remotes/origin/HEAD, which is what should matter.\n+\tgit -C pm-head-indep commit --allow-empty -m unrelated &&\n+\tgit -C pm-head-indep checkout --detach &&\n+\tgit -C pm-head-indep reset --hard HEAD^ &&\n+\n+\tgit -C pm-head-indep branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-head-indep rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips merged-ness check when <remote>/HEAD is unset' '\n+\ttest_when_finished \"rm -rf pm-no-head\" &&\n+\tgit clone pm-upstream pm-no-head &&\n+\tgit -C pm-no-head checkout -b one --track origin/one &&\n+\ttest_commit -C pm-no-head unpushed &&\n+\tgit -C pm-no-head checkout - &&\n+\n+\tgit -C pm-no-head update-ref -d refs/remotes/origin/HEAD &&\n+\tgit -C pm-no-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-no-head branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-no-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default config push.default current &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias config push.default current &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --force --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n+\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n+\tgit clone pm-upstream pm-pushdefault &&\n+\tgit -C pm-pushdefault branch --track topic origin/one &&\n+\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n+\tgit -C pm-pushdefault checkout --detach &&\n+\tgit -C pm-pushdefault branch --prune-merged origin &&\n+\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543039","messageId":"717fc6758e0d02d5e2c1e1834c5f4b6d884393b5.1778492691.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v6 4/5] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:50Z","receivedAt":"2026-05-11T09:45:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 10 ++++----\n builtin/branch.c                 | 31 +++++++++++++++++++++----\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 79 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..4662ef35c1 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex a5e869270d..9807d3c218 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,15 +216,15 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking branch (the branch `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n As a safety check, branches with commits not yet integrated into\n the remote's default branch are refused. With `--force` (or `-f`),\n delete them regardless. The currently checked-out branch in any\n-worktree is always preserved.\n+worktree is always preserved, as is any branch with\n+`branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex d2f07cddd8..7b356e250e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -869,15 +869,18 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n \t\tconst char *upstream;\n \t\tconst char *remote_name;\n \t\tconst char *slash;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -887,18 +890,38 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \t\tif (upstream &&\n \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n \t\t}\n \n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n-\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n \t\tremote_name = push_ref + strlen(\"refs/remotes/\");\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex ca3d06a1ec..fabff84f16 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1915,4 +1915,44 @@ test_expect_success '--prune-merged spares branches whose push ref is the defaul\n \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543040","messageId":"be255729575abde11208c02ae217640cfe76b095.1778492691.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v6 5/5] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-11T09:44:51Z","receivedAt":"2026-05-11T09:45:03Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 41 +++++++++++++++++++++++++----------\n t/t3200-branch.sh             | 40 ++++++++++++++++++++++++++++++++++\n 3 files changed, 76 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 9807d3c218..e5fe82de39 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,11 @@ delete them regardless. The currently checked-out branch in any\n worktree is always preserved, as is any branch with\n `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 7b356e250e..5f771d2f32 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -703,6 +703,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -792,7 +799,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -805,6 +812,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -818,15 +827,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -849,8 +858,8 @@ static struct commit *resolve_remote_head(const char *remote_name)\n \treturn commit;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -861,10 +870,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -1001,6 +1011,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1058,6 +1069,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1101,6 +1115,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1154,10 +1171,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex fabff84f16..efededd1f0 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1955,4 +1976,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543098","messageId":"xmqqse7xr0t7.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"Re: [PATCH v6 0/5] branch: prune-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-11T23:20:04Z","receivedAt":"2026-05-11T23:20:07Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n>  * --prune-merged now measures merged-ness against the remote's default\n>    branch instead of the candidate's upstream — so the decision no longer\n>    depends on which branch happens to be checked out locally.\n\nI may be misreading the above and misunderstood you, but if you mean\nthat the feature now checks with remote/origin/master when I have a\nlocal branch that were forked from remote/origin/todo and set to\nmerge new changes from there, I do not think it is a good change.\n\nThe two remote-tracking branches may not even share any commit.\n\nThe \"what it tracks or HEAD\" logic I raised as questionable is in\nthe function builtin/branch.c:branch_merged() that is called from\nthe function check_branch_commit() you updated and used in the\nimplementation of prune-merged.  It does branch_get_upstream() to\nfind the tip of remote-tracking branch that the target branch builds\nupon, and performs comparison (which is very sensible).  I do not\nthink you want to change it to check with remotes/<remote>/HEAD\ninstead, as the upstream of the local branch may not be building on\ntheir HEAD at all.  But when the upstream is not found, the code\nmakes the reference_rev variable fall back to head_rev, and checks\nif the commit at the tip of the target branch is already merged\nthere.\n\nIt is still not clear to me if we want to optionally disable this\nfallback to HEAD, but a quick scan of branch_merged() tells me that\nit is prepared to see NULL in head_rev.\n\n\n"},{"id":"543150","messageId":"20260512073510.63435-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqqse7xr0t7.fsf@gitster.g","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-12T07:35:10Z","receivedAt":"2026-05-12T07:35:16Z","isPatch":true,"body":"> I may be misreading the above and misunderstood you, but if you mean\n> that the feature now checks with remote/origin/master when I have a\n> local branch that were forked from remote/origin/todo and set to\n> merge new changes from there, I do not think it is a good change.\n\nI think you are right. My latest code assumes that everyone works toward\nthe default branch, which is what I do 99% of the time, but yeah, it should\nbe more agnostic from different workflow.\n\nI'll take another look.\n\n\nHarald\n"},{"id":"543152","messageId":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v6.git.git.1778492691.gitgitgadget@gmail.com","subject":"[PATCH v7 0/5] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:44Z","receivedAt":"2026-05-12T08:23:54Z","isPatch":true,"body":" * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nHarald Nordgren (5):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  33 +++\n builtin/branch.c                 | 332 +++++++++++++++++++++++++++++--\n t/t3200-branch.sh                | 280 ++++++++++++++++++++++++++\n 4 files changed, 635 insertions(+), 17 deletions(-)\n\n\nbase-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v7\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v7\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v6:\n\n 1:  fb9817b220 = 1:  22fa8515df branch: add --forked <remote>\n 2:  42a2f93d44 = 2:  b443f0f367 branch: let delete_branches warn instead of error on bulk refusal\n 3:  604ecb8965 ! 3:  a245009893 branch: add --prune-merged <remote>\n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      +\tbeen pruned upstream.\n      ++\n      +As a safety check, branches with commits not yet integrated into\n     -+the remote's default branch are refused. With `--force` (or `-f`),\n     -+delete them regardless. The currently checked-out branch in any\n     -+worktree is always preserved.\n     ++their upstream remote-tracking branch are refused; if the upstream\n     ++itself is gone, the remote's default branch is consulted instead.\n     ++With `--force` (or `-f`), delete refused branches regardless. The\n     ++currently checked-out branch in any worktree is always preserved.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c\n       #include \"column.h\"\n       #include \"utf8.h\"\n       #include \"ref-filter.h\"\n     +@@ builtin/branch.c: static const char *branch_get_color(enum color_branch ix)\n     + }\n     + \n     + static int branch_merged(int kind, const char *name,\n     +-\t\t\t struct commit *rev, struct commit *head_rev)\n     ++\t\t\t struct commit *rev, struct commit *head_rev,\n     ++\t\t\t int no_head_fallback)\n     + {\n     + \t/*\n     + \t * This checks whether the merge bases of branch and HEAD (or\n     +@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     + \t\t\t\t\t &oid, NULL)) != NULL)\n     + \t\t\treference_rev = lookup_commit_reference(the_repository,\n     + \t\t\t\t\t\t\t\t&oid);\n     ++\n     ++\t\tif (!reference_rev && no_head_fallback && upstream &&\n     ++\t\t    starts_with(upstream, \"refs/remotes/\")) {\n     ++\t\t\tconst char *remote_name = upstream + strlen(\"refs/remotes/\");\n     ++\t\t\tconst char *slash = strchr(remote_name, '/');\n     ++\t\t\tif (slash) {\n     ++\t\t\t\tstruct strbuf head_ref = STRBUF_INIT;\n     ++\t\t\t\tstrbuf_add(&head_ref, \"refs/remotes/\", strlen(\"refs/remotes/\"));\n     ++\t\t\t\tstrbuf_add(&head_ref, remote_name, slash - remote_name);\n     ++\t\t\t\tstrbuf_addstr(&head_ref, \"/HEAD\");\n     ++\t\t\t\tif (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n     ++\t\t\t\t\t\t\t    head_ref.buf,\n     ++\t\t\t\t\t\t\t    RESOLVE_REF_READING,\n     ++\t\t\t\t\t\t\t    &oid, NULL))\n     ++\t\t\t\t\treference_rev = lookup_commit_reference(the_repository,\n     ++\t\t\t\t\t\t\t\t\t\t&oid);\n     ++\t\t\t\tstrbuf_release(&head_ref);\n     ++\t\t\t}\n     ++\t\t}\n     + \t}\n     +-\tif (!reference_rev)\n     ++\tif (!reference_rev) {\n     ++\t\tif (no_head_fallback) {\n     ++\t\t\tfree(reference_name_to_free);\n     ++\t\t\treturn 0;\n     ++\t\t}\n     + \t\treference_rev = head_rev;\n     ++\t}\n     + \n     + \tmerged = reference_rev ? repo_in_merge_bases(the_repository, rev,\n     + \t\t\t\t\t\t     reference_rev) : 0;\n     +@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     + \t * any of the following code, but during the transition period,\n     + \t * a gentle reminder is in order.\n     + \t */\n     +-\tif (head_rev != reference_rev) {\n     ++\tif (!no_head_fallback && head_rev != reference_rev) {\n     + \t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n     + \t\tif (expect < 0)\n     + \t\t\texit(128);\n      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n       \t\t\t       const struct object_id *oid, struct commit *head_rev,\n     -+\t\t\t       struct commit *head_rev_override,\n     -+\t\t\t       int use_head_rev_override,\n     ++\t\t\t       int no_head_fallback,\n       \t\t\t       int kinds, int force, int warn_only,\n       \t\t\t       int *n_not_merged)\n       {\n     - \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     -+\tint merged;\n     -+\n     - \tif (!force && !rev) {\n     +@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n       \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n       \t\treturn -1;\n       \t}\n      -\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     -+\tif (use_head_rev_override) {\n     -+\t\tif (!head_rev_override)\n     -+\t\t\treturn 0;\n     -+\t\tmerged = repo_in_merge_bases(the_repository, rev,\n     -+\t\t\t\t\t     head_rev_override);\n     -+\t\tif (merged < 0)\n     -+\t\t\texit(128);\n     -+\t} else {\n     -+\t\tmerged = branch_merged(kinds, branchname, rev, head_rev);\n     -+\t}\n     -+\tif (!force && !merged) {\n     ++\tif (!force && !branch_merged(kinds, branchname, rev, head_rev,\n     ++\t\t\t\t     no_head_fallback)) {\n       \t\tif (warn_only) {\n       \t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n       \t\t\t\tbranchname);\n     @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       \n      -static int delete_branches(int argc, const char **argv, int force, int kinds,\n      +static int delete_branches(int argc, const char **argv,\n     -+\t\t\t   struct commit **head_rev_overrides,\n     ++\t\t\t   int no_head_fallback,\n      +\t\t\t   int force, int kinds,\n       \t\t\t   int quiet, int warn_only, int *n_not_merged)\n       {\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      -\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n      -\t\t\t\t\tforce, warn_only, n_not_merged)) {\n      +\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n     -+\t\t\t\t\thead_rev_overrides ? head_rev_overrides[i] : NULL,\n     -+\t\t\t\t\t!!head_rev_overrides,\n     ++\t\t\t\t\tno_head_fallback,\n      +\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n       \t\t\tif (!warn_only)\n       \t\t\t\tret = 1;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n       \treturn 0;\n       }\n       \n     -+static struct commit *resolve_remote_head(const char *remote_name)\n     -+{\n     -+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     -+\tstruct strbuf head_ref = STRBUF_INIT;\n     -+\tstruct object_id oid;\n     -+\tstruct commit *commit = NULL;\n     -+\n     -+\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote_name);\n     -+\tif (refs_resolve_ref_unsafe(refs, head_ref.buf, RESOLVE_REF_READING,\n     -+\t\t\t\t    &oid, NULL))\n     -+\t\tcommit = lookup_commit_reference(the_repository, &oid);\n     -+\tstrbuf_release(&head_ref);\n     -+\treturn commit;\n     -+}\n     -+\n      +static int prune_merged_branches(int argc, const char **argv, int force,\n      +\t\t\t\t int quiet)\n      +{\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n      +\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n      +\tstruct strvec deletable = STRVEC_INIT;\n     -+\tstruct commit **head_rev_overrides = NULL;\n     -+\tsize_t alloc = 0;\n      +\tstruct string_list_item *item;\n      +\tint n_not_merged = 0;\n      +\tint ret = 0;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tstruct branch *branch;\n      +\t\tconst char *push_ref;\n      +\t\tconst char *upstream;\n     -+\t\tconst char *remote_name;\n     -+\t\tconst char *slash;\n      +\n      +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n      +\t\tif (branch_checked_out(full.buf)) {\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n      +\t\t\tcontinue;\n      +\n     -+\t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n     -+\t\tremote_name = push_ref + strlen(\"refs/remotes/\");\n     -+\t\tslash = strchr(remote_name, '/');\n     -+\t\tif (slash) {\n     -+\t\t\tchar *name = xstrndup(remote_name, slash - remote_name);\n     -+\t\t\thead_rev_overrides[deletable.nr] = resolve_remote_head(name);\n     -+\t\t\tfree(name);\n     -+\t\t} else {\n     -+\t\t\thead_rev_overrides[deletable.nr] = NULL;\n     -+\t\t}\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n      +\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n     -+\t\t\t\t      head_rev_overrides, force,\n     ++\t\t\t\t      1, force,\n      +\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n      +\t\t\t\t      1, &n_not_merged);\n      +\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\t\tn_not_merged);\n      +\n      +\tstrvec_clear(&deletable);\n     -+\tfree(head_rev_overrides);\n      +\tstring_list_clear(&candidates, 0);\n      +\tstring_list_clear(&protected_default_refs, 0);\n      +\treturn ret;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tif (!argc)\n       \t\t\tdie(_(\"branch name required\"));\n      -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     -+\t\tret = delete_branches(argc, argv, NULL, delete > 1, filter.kind,\n     ++\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n       \t\t\t\t      quiet, 0, NULL);\n       \t\tgoto out;\n       \t} else if (forked) {\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged measures merged-ness against <remote>/HEAD, not local HEAD' '\n     -+\ttest_when_finished \"rm -rf pm-head-indep\" &&\n     -+\tgit clone pm-upstream pm-head-indep &&\n     -+\tgit -C pm-head-indep branch one --track origin/one &&\n     -+\tgit -C pm-head-indep update-ref -d refs/remotes/origin/one &&\n     ++test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '\n     ++\ttest_when_finished \"rm -rf pm-fallback\" &&\n     ++\tgit clone pm-upstream pm-fallback &&\n     ++\tgit -C pm-fallback branch one --track origin/one &&\n     ++\tgit -C pm-fallback update-ref -d refs/remotes/origin/one &&\n      +\t# Detach HEAD to an unrelated commit so the candidate is not\n     -+\t# reachable from local HEAD; it is still reachable from\n     -+\t# refs/remotes/origin/HEAD, which is what should matter.\n     -+\tgit -C pm-head-indep commit --allow-empty -m unrelated &&\n     -+\tgit -C pm-head-indep checkout --detach &&\n     -+\tgit -C pm-head-indep reset --hard HEAD^ &&\n     ++\t# reachable from local HEAD. The upstream origin/one is now\n     ++\t# gone; the merged-ness check should fall back to\n     ++\t# refs/remotes/origin/HEAD, against which \"one\" is reachable.\n     ++\tgit -C pm-fallback commit --allow-empty -m unrelated &&\n     ++\tgit -C pm-fallback checkout --detach &&\n     ++\tgit -C pm-fallback reset --hard HEAD^ &&\n      +\n     -+\tgit -C pm-head-indep branch --prune-merged origin &&\n     ++\tgit -C pm-fallback branch --prune-merged origin &&\n      +\n     -+\ttest_must_fail git -C pm-head-indep rev-parse --verify refs/heads/one\n     ++\ttest_must_fail git -C pm-fallback rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged skips merged-ness check when <remote>/HEAD is unset' '\n     -+\ttest_when_finished \"rm -rf pm-no-head\" &&\n     -+\tgit clone pm-upstream pm-no-head &&\n     -+\tgit -C pm-no-head checkout -b one --track origin/one &&\n     -+\ttest_commit -C pm-no-head unpushed &&\n     -+\tgit -C pm-no-head checkout - &&\n     ++test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '\n     ++\ttest_when_finished \"rm -rf pm-both-gone\" &&\n     ++\tgit clone pm-upstream pm-both-gone &&\n     ++\tgit -C pm-both-gone checkout -b one --track origin/one &&\n     ++\ttest_commit -C pm-both-gone unpushed &&\n     ++\tgit -C pm-both-gone checkout - &&\n      +\n     -+\tgit -C pm-no-head update-ref -d refs/remotes/origin/HEAD &&\n     -+\tgit -C pm-no-head update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-no-head branch --prune-merged origin &&\n     ++\tgit -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&\n     ++\tgit -C pm-both-gone update-ref -d refs/remotes/origin/one &&\n     ++\tgit -C pm-both-gone branch --prune-merged origin 2>err &&\n     ++\ttest_grep \"not fully merged\" err &&\n      +\n     -+\ttest_must_fail git -C pm-no-head rev-parse --verify refs/heads/one\n     ++\tgit -C pm-both-gone rev-parse --verify refs/heads/one\n      +'\n      +\n      +test_expect_success '--prune-merged never deletes the checked-out branch' '\n 4:  717fc6758e ! 4:  2c3f751569 branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      +\tthat name has since been pruned upstream.\n       +\n       As a safety check, branches with commits not yet integrated into\n     - the remote's default branch are refused. With `--force` (or `-f`),\n     - delete them regardless. The currently checked-out branch in any\n     --worktree is always preserved.\n     -+worktree is always preserved, as is any branch with\n     -+`branch.<name>.pruneMerged` set to `false`.\n     + their upstream remote-tracking branch are refused; if the upstream\n     + itself is gone, the remote's default branch is consulted instead.\n     + With `--force` (or `-f`), delete refused branches regardless. The\n     +-currently checked-out branch in any worktree is always preserved.\n     ++currently checked-out branch in any worktree is always preserved,\n     ++as is any branch with `branch.<name>.pruneMerged` set to `false`.\n       \n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\tstruct branch *branch;\n       \t\tconst char *push_ref;\n       \t\tconst char *upstream;\n     - \t\tconst char *remote_name;\n     - \t\tconst char *slash;\n      +\t\tint opt_out = 0;\n       \n       \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\t}\n      +\t\tstrbuf_release(&key);\n       \n     - \t\tALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc);\n     - \t\tremote_name = push_ref + strlen(\"refs/remotes/\");\n     + \t\tstrvec_push(&deletable, short_name);\n     + \t}\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul\n 5:  be25572957 ! 5:  f79707ce7c branch: add --all-remotes flag\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: delete them regardless. The currently checked-out branch in any\n     - worktree is always preserved, as is any branch with\n     - `branch.<name>.pruneMerged` set to `false`.\n     +@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete refused branches regardless. The\n     + currently checked-out branch in any worktree is always preserved,\n     + as is any branch with `branch.<name>.pruneMerged` set to `false`.\n       \n      +`--all-remotes`::\n      +\tWith `--forked` or `--prune-merged`, act on every\n     @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n       \tfor_each_string_list_item(item, &out)\n       \t\tputs(item->string);\n       \n     -@@ builtin/branch.c: static struct commit *resolve_remote_head(const char *remote_name)\n     - \treturn commit;\n     +@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n     + \treturn 0;\n       }\n       \n      -static int prune_merged_branches(int argc, const char **argv, int force,\n\n-- \ngitgitgadget\n"},{"id":"543153","messageId":"22fa8515df4058875a7a6f53e4566493aaf193cf.1778574229.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v7 1/5] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:45Z","receivedAt":"2026-05-12T08:23:55Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543154","messageId":"b443f0f3676f0382929df0ab95ba7faf0c80193a.1778574229.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v7 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:46Z","receivedAt":"2026-05-12T08:23:57Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543155","messageId":"a2450098932a734f47ebe5f2c800bd63f760b64c.1778574229.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v7 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:47Z","receivedAt":"2026-05-12T08:23:58Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list,\nrefusing any whose tip is not reachable from the remote's default\nbranch. With --force, delete unconditionally. The currently\nchecked-out branch in any worktree is always preserved.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  16 +++\n builtin/branch.c              | 181 ++++++++++++++++++++++++++++++----\n t/t3200-branch.sh             | 146 +++++++++++++++++++++++++++\n 3 files changed, 323 insertions(+), 20 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..1a5a5a9a54 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,21 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only when the branch's\n+\tpush destination remote-tracking branch (the branch `git push`\n+\twould update; see `branch_get_push` semantics) no longer\n+\tresolves locally. In other words: the branch was pushed\n+\tunder some name on _<remote>_, and that name has since\n+\tbeen pruned upstream.\n++\n+As a safety check, branches with commits not yet integrated into\n+their upstream remote-tracking branch are refused; if the upstream\n+itself is gone, the remote's default branch is consulted instead.\n+With `--force` (or `-f`), delete refused branches regardless. The\n+currently checked-out branch in any worktree is always preserved.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..2eb7433b28 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -131,7 +132,8 @@ static const char *branch_get_color(enum color_branch ix)\n }\n \n static int branch_merged(int kind, const char *name,\n-\t\t\t struct commit *rev, struct commit *head_rev)\n+\t\t\t struct commit *rev, struct commit *head_rev,\n+\t\t\t int no_head_fallback)\n {\n \t/*\n \t * This checks whether the merge bases of branch and HEAD (or\n@@ -155,9 +157,33 @@ static int branch_merged(int kind, const char *name,\n \t\t\t\t\t &oid, NULL)) != NULL)\n \t\t\treference_rev = lookup_commit_reference(the_repository,\n \t\t\t\t\t\t\t\t&oid);\n+\n+\t\tif (!reference_rev && no_head_fallback && upstream &&\n+\t\t    starts_with(upstream, \"refs/remotes/\")) {\n+\t\t\tconst char *remote_name = upstream + strlen(\"refs/remotes/\");\n+\t\t\tconst char *slash = strchr(remote_name, '/');\n+\t\t\tif (slash) {\n+\t\t\t\tstruct strbuf head_ref = STRBUF_INIT;\n+\t\t\t\tstrbuf_add(&head_ref, \"refs/remotes/\", strlen(\"refs/remotes/\"));\n+\t\t\t\tstrbuf_add(&head_ref, remote_name, slash - remote_name);\n+\t\t\t\tstrbuf_addstr(&head_ref, \"/HEAD\");\n+\t\t\t\tif (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n+\t\t\t\t\t\t\t    head_ref.buf,\n+\t\t\t\t\t\t\t    RESOLVE_REF_READING,\n+\t\t\t\t\t\t\t    &oid, NULL))\n+\t\t\t\t\treference_rev = lookup_commit_reference(the_repository,\n+\t\t\t\t\t\t\t\t\t\t&oid);\n+\t\t\t\tstrbuf_release(&head_ref);\n+\t\t\t}\n+\t\t}\n \t}\n-\tif (!reference_rev)\n+\tif (!reference_rev) {\n+\t\tif (no_head_fallback) {\n+\t\t\tfree(reference_name_to_free);\n+\t\t\treturn 0;\n+\t\t}\n \t\treference_rev = head_rev;\n+\t}\n \n \tmerged = reference_rev ? repo_in_merge_bases(the_repository, rev,\n \t\t\t\t\t\t     reference_rev) : 0;\n@@ -171,7 +197,7 @@ static int branch_merged(int kind, const char *name,\n \t * any of the following code, but during the transition period,\n \t * a gentle reminder is in order.\n \t */\n-\tif (head_rev != reference_rev) {\n+\tif (!no_head_fallback && head_rev != reference_rev) {\n \t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n \t\tif (expect < 0)\n \t\t\texit(128);\n@@ -192,6 +218,7 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n+\t\t\t       int no_head_fallback,\n \t\t\t       int kinds, int force, int warn_only,\n \t\t\t       int *n_not_merged)\n {\n@@ -200,7 +227,8 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!force && !branch_merged(kinds, branchname, rev, head_rev,\n+\t\t\t\t     no_head_fallback)) {\n \t\tif (warn_only) {\n \t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n \t\t\t\tbranchname);\n@@ -227,7 +255,9 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n+static int delete_branches(int argc, const char **argv,\n+\t\t\t   int no_head_fallback,\n+\t\t\t   int force, int kinds,\n \t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n@@ -317,8 +347,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n-\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n+\t\t\t\t\tno_head_fallback,\n+\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n \t\t\tif (!warn_only)\n \t\t\t\tret = 1;\n \t\t\tgoto next;\n@@ -753,36 +784,139 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *push_ref;\n+\t\tconst char *upstream;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (upstream &&\n+\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push_ref)\n+\t\t\tcontinue;\n+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t    push_ref))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      1, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +959,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +1015,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1061,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1071,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -971,12 +1109,15 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..f0d1250dbf 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,150 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream branch one base &&\n+\tgit -C pm-upstream branch two base\n+'\n+\n+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n+\ttest_when_finished \"rm -rf pm-clean\" &&\n+\tgit clone pm-upstream pm-clean &&\n+\tgit -C pm-clean branch one --track origin/one &&\n+\tgit -C pm-clean branch two --track origin/two &&\n+\n+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-clean branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n+\tgit -C pm-clean rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n+\ttest_when_finished \"rm -rf pm-inflight\" &&\n+\tgit clone pm-upstream pm-inflight &&\n+\tgit -C pm-inflight branch one --track origin/one &&\n+\n+\tgit -C pm-inflight branch --prune-merged origin &&\n+\n+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged skips branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n+\ttest_commit -C pm-unmerged unpushed &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-force unpushed &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-fallback\" &&\n+\tgit clone pm-upstream pm-fallback &&\n+\tgit -C pm-fallback branch one --track origin/one &&\n+\tgit -C pm-fallback update-ref -d refs/remotes/origin/one &&\n+\t# Detach HEAD to an unrelated commit so the candidate is not\n+\t# reachable from local HEAD. The upstream origin/one is now\n+\t# gone; the merged-ness check should fall back to\n+\t# refs/remotes/origin/HEAD, against which \"one\" is reachable.\n+\tgit -C pm-fallback commit --allow-empty -m unrelated &&\n+\tgit -C pm-fallback checkout --detach &&\n+\tgit -C pm-fallback reset --hard HEAD^ &&\n+\n+\tgit -C pm-fallback branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-fallback rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '\n+\ttest_when_finished \"rm -rf pm-both-gone\" &&\n+\tgit clone pm-upstream pm-both-gone &&\n+\tgit -C pm-both-gone checkout -b one --track origin/one &&\n+\ttest_commit -C pm-both-gone unpushed &&\n+\tgit -C pm-both-gone checkout - &&\n+\n+\tgit -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&\n+\tgit -C pm-both-gone update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-both-gone branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\n+\tgit -C pm-both-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one --track origin/one &&\n+\n+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n+\tgit clone pm-upstream pm-pushdiff &&\n+\tgit -C pm-pushdiff config push.default current &&\n+\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n+\n+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default config push.default current &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias config push.default current &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --force --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n+\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n+\tgit clone pm-upstream pm-pushdefault &&\n+\tgit -C pm-pushdefault branch --track topic origin/one &&\n+\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n+\tgit -C pm-pushdefault checkout --detach &&\n+\tgit -C pm-pushdefault branch --prune-merged origin &&\n+\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543156","messageId":"2c3f7515693f14f5f782337fe58fd122e40a3b63.1778574229.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v7 4/5] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:48Z","receivedAt":"2026-05-12T08:24:00Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged (and from fetch --prune-merged), even with --force.\nUseful for keeping a topic branch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    | 10 ++++----\n builtin/branch.c                 | 31 +++++++++++++++++++++----\n t/t3200-branch.sh                | 40 ++++++++++++++++++++++++++++++++\n 4 files changed, 79 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..4662ef35c1 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 1a5a5a9a54..87a26da0cc 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,16 +216,16 @@ Each _<remote>_ may be either the name of a configured remote\n \tDelete the local branches that `--forked` would list for\n \tthe same _<remote>_ arguments, but only when the branch's\n \tpush destination remote-tracking branch (the branch `git push`\n-\twould update; see `branch_get_push` semantics) no longer\n-\tresolves locally. In other words: the branch was pushed\n-\tunder some name on _<remote>_, and that name has since\n-\tbeen pruned upstream.\n+\twould update) no longer resolves locally. In other words:\n+\tthe branch was pushed under some name on _<remote>_, and\n+\tthat name has since been pruned upstream.\n +\n As a safety check, branches with commits not yet integrated into\n their upstream remote-tracking branch are refused; if the upstream\n itself is gone, the remote's default branch is consulted instead.\n With `--force` (or `-f`), delete refused branches regardless. The\n-currently checked-out branch in any worktree is always preserved.\n+currently checked-out branch in any worktree is always preserved,\n+as is any branch with `branch.<name>.pruneMerged` set to `false`.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2eb7433b28..c48af54301 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -864,13 +864,16 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *push_ref;\n \t\tconst char *upstream;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -880,18 +883,38 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \t\tif (upstream &&\n \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n \t\t}\n \n \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n-\t\tif (!push_ref)\n+\t\tif (!push_ref) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t    push_ref))\n+\t\t\t\t    push_ref)) {\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n-\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex f0d1250dbf..23b82615f5 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1917,4 +1917,44 @@ test_expect_success '--prune-merged spares branches whose push ref is the defaul\n \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one --track origin/one &&\n+\tgit -C pm-optout branch two --track origin/two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n+\ttest_commit -C pm-optout-force unpushed &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one --track origin/one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543157","messageId":"f79707ce7c6a3c5cd5308c4e3c43b52cfa585b2a.1778574229.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v7 5/5] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T08:23:49Z","receivedAt":"2026-05-12T08:24:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 ++++++--\n builtin/branch.c              | 41 +++++++++++++++++++++++++----------\n t/t3200-branch.sh             | 40 ++++++++++++++++++++++++++++++++++\n 3 files changed, 76 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 87a26da0cc..6fde8f642e 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -227,6 +227,11 @@ With `--force` (or `-f`), delete refused branches regardless. The\n currently checked-out branch in any worktree is always preserved,\n as is any branch with `branch.<name>.pruneMerged` set to `false`.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c48af54301..22c30164ca 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -715,6 +715,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -804,7 +811,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -817,6 +824,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -830,15 +839,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -846,8 +855,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -856,10 +865,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -983,6 +993,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1083,6 +1097,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1136,10 +1153,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 23b82615f5..4bd92fe430 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1957,4 +1978,23 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other branch foreign other-base &&\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one --track origin/one &&\n+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n+\n+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543174","messageId":"xmqq5x4sn393.fsf@gitster.g","threadId":"65574","inReplyTo":"a2450098932a734f47ebe5f2c800bd63f760b64c.1778574229.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v7 3/5] branch: add --prune-merged <remote>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-12T13:53:12Z","receivedAt":"2026-05-12T13:53:15Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +`--prune-merged`::\n> +\tDelete the local branches that `--forked` would list for\n> +\tthe same _<remote>_ arguments, but only when the branch's\n> +\tpush destination remote-tracking branch (the branch `git push`\n> +\twould update; see `branch_get_push` semantics) no longer\n> +\tresolves locally.\n\nI thought the thing you were aiming for is this scenario:\n\n    git fetch origin\n    git checkout -t -b hn/topic origin/next\n    ... work work work ...\n    git push origin\n    ... the above pushes the current hn/topic to update their next\n    git checkout master ;# or anywhere other than hn/topic\n    git fetch origin\n    git branch --prune-merged origin\n\nThe last step notices that our local hn/topic has been merged at the\nremote to the target branch 'next', and the second-to-last fetch\nmakes us notice that origin/next now has our hn/topic merged, so we\nno longer have a reason to keep hn/topic around.\n\nBut the above description uses quite different condition.  You want\nto notice that _they_ removed 'next' (for that, the second-to-last\nfetch may need to be run with --prune) and then remove our local\nhn/topic, but to me, that sounds nonsense for two reasons.\n\n (1) Their 'next' is something contributors may fork from to work\n     on.  You exactly did that with hn/topic branch of your own.\n     Why would we even expect that to go away?\n\n (2) When disappearance of their 'next' is fetched to our\n     remote-tracking namespace, we would not even know if hn/topic\n     that used to fork from has been already integrated and stashed\n     safely on some other branch on the remote.  It sounds very\n     unsafe to remove it based on disappearance of origin/next\n     remote-tracking branch.\n\n> In other words: the branch was pushed\n> +\tunder some name on _<remote>_, and that name has since\n> +\tbeen pruned upstream.\n> ++\n> +As a safety check, branches with commits not yet integrated into\n> +their upstream remote-tracking branch are refused; if the upstream\n> +itself is gone, the remote's default branch is consulted instead.\n\nAgain, this is as nonsense as our example in an earlier iteration of\nhaving a topic forked from my 'todo' branch while the HEAD is\npointing at the default branch that is 'master'.  If the upstream\nitself is gone, removing anything based on some other criteria\ncannot by definition a \"safety check\".  I'd suggest rethinking the\nlogic.\n\n> @@ -171,7 +197,7 @@ static int branch_merged(int kind, const char *name,\n>  \t * any of the following code, but during the transition period,\n>  \t * a gentle reminder is in order.\n>  \t */\n> -\tif (head_rev != reference_rev) {\n> +\tif (!no_head_fallback && head_rev != reference_rev) {\n\nI somehow thought that the necessary check at the lowest level can\nreuse most of the \"branch -d\" protection logic, except that it needs\nto pass NULL for head_rev from check_branch_commit() down to\nbranch_merged() when doing \"branch --prune-merged\".  Do we really\nneed an extra no_head_fallback parameter?\n"},{"id":"543196","messageId":"20260512170041.92130-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqq5x4sn393.fsf@gitster.g","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-12T17:00:41Z","receivedAt":"2026-05-12T17:00:44Z","isPatch":true,"body":"> But the above description uses quite different condition.  You want\n> to notice that _they_ removed 'next' (for that, the second-to-last\n> fetch may need to be run with --prune) and then remove our local\n> hn/topic, but to me, that sounds nonsense for two reasons.\n\nI wanted to be able to be aggresice with deleting, but maybe this went a\nbit overboard. Would still be nice to have a nuclear option.\n\n\nHarald\n"},{"id":"543197","messageId":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v7.git.git.1778574229.gitgitgadget@gmail.com","subject":"[PATCH v8 0/5] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:33Z","receivedAt":"2026-05-12T17:07:42Z","isPatch":true,"body":" * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nHarald Nordgren (5):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  38 ++++\n builtin/branch.c                 | 291 +++++++++++++++++++++++++++++--\n t/t3200-branch.sh                | 235 +++++++++++++++++++++++++\n 4 files changed, 555 insertions(+), 16 deletions(-)\n\n\nbase-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v8\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v8\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v7:\n\n 1:  22fa8515df = 1:  22fa8515df branch: add --forked <remote>\n 2:  b443f0f367 = 2:  b443f0f367 branch: let delete_branches warn instead of error on bulk refusal\n 3:  a245009893 ! 3:  3032e9c39a branch: add --prune-merged <remote>\n     @@ Metadata\n       ## Commit message ##\n          branch: add --prune-merged <remote>\n      \n     -    Delete the local branches that --forked <remote> would list,\n     -    refusing any whose tip is not reachable from the remote's default\n     -    branch. With --force, delete unconditionally. The currently\n     -    checked-out branch in any worktree is always preserved.\n     +    Delete the local branches that --forked <remote> would list, but\n     +    only those whose tip is reachable from their configured upstream\n     +    remote-tracking branch (branch.<name>.merge): the work has already\n     +    landed on the upstream it tracks, so the local copy is no longer\n     +    needed.\n     +\n     +    A branch whose upstream no longer resolves locally is left alone --\n     +    its disappearance is not, on its own, evidence that the work was\n     +    integrated. With --force, skip the reachability check and delete\n     +    every branch in the candidate set. The currently checked-out\n     +    branch in any worktree is always preserved, as is the local branch\n     +    that mirrors <remote>'s default branch.\n     +\n     +    Reachability is read from whatever the remote-tracking refs say\n     +    locally, so the natural workflow is\n     +\n     +            git fetch <remote>\n     +            git branch --prune-merged <remote>\n     +\n     +    with no implicit cleanup driven by fetch itself.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n       \n      +`--prune-merged`::\n      +\tDelete the local branches that `--forked` would list for\n     -+\tthe same _<remote>_ arguments, but only when the branch's\n     -+\tpush destination remote-tracking branch (the branch `git push`\n     -+\twould update; see `branch_get_push` semantics) no longer\n     -+\tresolves locally. In other words: the branch was pushed\n     -+\tunder some name on _<remote>_, and that name has since\n     -+\tbeen pruned upstream.\n     ++\tthe same _<remote>_ arguments, but only those whose tip is\n     ++\treachable from their configured upstream remote-tracking\n     ++\tbranch (`branch.<name>.merge`). In other words: the work on\n     ++\tthe branch has already landed on the upstream it tracks, so\n     ++\tthe local copy is no longer needed.\n     +++\n     ++Run `git fetch` first so the upstream remote-tracking branches\n     ++reflect the current state of _<remote>_; reachability is checked\n     ++against whatever the remote-tracking refs say locally.\n      ++\n     -+As a safety check, branches with commits not yet integrated into\n     -+their upstream remote-tracking branch are refused; if the upstream\n     -+itself is gone, the remote's default branch is consulted instead.\n     -+With `--force` (or `-f`), delete refused branches regardless. The\n     -+currently checked-out branch in any worktree is always preserved.\n     ++A branch whose upstream no longer resolves locally is left alone\n     ++(its disappearance is not, on its own, evidence that the work was\n     ++integrated). With `--force` (or `-f`), the reachability check is\n     ++skipped and every branch in the candidate set is deleted. The\n     ++currently checked-out branch in any worktree is always preserved,\n     ++as is the local branch that mirrors _<remote>_'s default branch.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c\n       #include \"column.h\"\n       #include \"utf8.h\"\n       #include \"ref-filter.h\"\n     -@@ builtin/branch.c: static const char *branch_get_color(enum color_branch ix)\n     - }\n     - \n     - static int branch_merged(int kind, const char *name,\n     --\t\t\t struct commit *rev, struct commit *head_rev)\n     -+\t\t\t struct commit *rev, struct commit *head_rev,\n     -+\t\t\t int no_head_fallback)\n     - {\n     - \t/*\n     - \t * This checks whether the merge bases of branch and HEAD (or\n     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     - \t\t\t\t\t &oid, NULL)) != NULL)\n     - \t\t\treference_rev = lookup_commit_reference(the_repository,\n     - \t\t\t\t\t\t\t\t&oid);\n     -+\n     -+\t\tif (!reference_rev && no_head_fallback && upstream &&\n     -+\t\t    starts_with(upstream, \"refs/remotes/\")) {\n     -+\t\t\tconst char *remote_name = upstream + strlen(\"refs/remotes/\");\n     -+\t\t\tconst char *slash = strchr(remote_name, '/');\n     -+\t\t\tif (slash) {\n     -+\t\t\t\tstruct strbuf head_ref = STRBUF_INIT;\n     -+\t\t\t\tstrbuf_add(&head_ref, \"refs/remotes/\", strlen(\"refs/remotes/\"));\n     -+\t\t\t\tstrbuf_add(&head_ref, remote_name, slash - remote_name);\n     -+\t\t\t\tstrbuf_addstr(&head_ref, \"/HEAD\");\n     -+\t\t\t\tif (refs_resolve_ref_unsafe(get_main_ref_store(the_repository),\n     -+\t\t\t\t\t\t\t    head_ref.buf,\n     -+\t\t\t\t\t\t\t    RESOLVE_REF_READING,\n     -+\t\t\t\t\t\t\t    &oid, NULL))\n     -+\t\t\t\t\treference_rev = lookup_commit_reference(the_repository,\n     -+\t\t\t\t\t\t\t\t\t\t&oid);\n     -+\t\t\t\tstrbuf_release(&head_ref);\n     -+\t\t\t}\n     -+\t\t}\n     - \t}\n     --\tif (!reference_rev)\n     -+\tif (!reference_rev) {\n     -+\t\tif (no_head_fallback) {\n     -+\t\t\tfree(reference_name_to_free);\n     -+\t\t\treturn 0;\n     -+\t\t}\n     - \t\treference_rev = head_rev;\n     -+\t}\n     - \n     - \tmerged = reference_rev ? repo_in_merge_bases(the_repository, rev,\n     - \t\t\t\t\t\t     reference_rev) : 0;\n      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \t * any of the following code, but during the transition period,\n       \t * a gentle reminder is in order.\n       \t */\n      -\tif (head_rev != reference_rev) {\n     -+\tif (!no_head_fallback && head_rev != reference_rev) {\n     - \t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n     +-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n     ++\tif (head_rev && head_rev != reference_rev) {\n     ++\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n       \t\tif (expect < 0)\n       \t\t\texit(128);\n     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     - \n     - static int check_branch_commit(const char *branchname, const char *refname,\n     - \t\t\t       const struct object_id *oid, struct commit *head_rev,\n     -+\t\t\t       int no_head_fallback,\n     - \t\t\t       int kinds, int force, int warn_only,\n     - \t\t\t       int *n_not_merged)\n     - {\n     -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n     - \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n     - \t\treturn -1;\n     - \t}\n     --\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     -+\tif (!force && !branch_merged(kinds, branchname, rev, head_rev,\n     -+\t\t\t\t     no_head_fallback)) {\n     - \t\tif (warn_only) {\n     - \t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n     - \t\t\t\tbranchname);\n     + \t\tif (expect == merged)\n      @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       \tstrbuf_release(&buf);\n       }\n     @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       \t\t\t   int quiet, int warn_only, int *n_not_merged)\n       {\n       \tstruct commit *head_rev = NULL;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t}\n     + \tbranch_name_pos = strcspn(fmt, \"%\");\n     + \n     +-\tif (!force)\n     ++\tif (!force && !no_head_fallback)\n     + \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n     + \n     + \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n       \t\t}\n       \n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      -\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n      -\t\t\t\t\tforce, warn_only, n_not_merged)) {\n      +\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n     -+\t\t\t\t\tno_head_fallback,\n      +\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n       \t\t\tif (!warn_only)\n       \t\t\t\tret = 1;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\tconst char *short_name = item->string;\n      +\t\tstruct strbuf full = STRBUF_INIT;\n      +\t\tstruct branch *branch;\n     -+\t\tconst char *push_ref;\n      +\t\tconst char *upstream;\n      +\n      +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\n      +\t\tbranch = branch_get(short_name);\n      +\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     -+\t\tif (upstream &&\n     -+\t\t    string_list_has_string(&protected_default_refs, upstream)) {\n     ++\t\tif (!upstream ||\n     ++\t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n     ++\t\t\t\t     upstream))\n     ++\t\t\tcontinue;\n     ++\t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n      +\t\t\tconst char *leaf = strrchr(upstream, '/');\n      +\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n      +\t\t\t\tcontinue;\n      +\t\t}\n      +\n     -+\t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n     -+\t\tif (!push_ref)\n     -+\t\t\tcontinue;\n     -+\t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n     -+\t\t\t\t    push_ref))\n     -+\t\t\tcontinue;\n     -+\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n     -+\t\t\tcontinue;\n     -+\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +test_expect_success '--prune-merged: setup' '\n      +\ttest_create_repo pm-upstream &&\n      +\ttest_commit -C pm-upstream base &&\n     -+\tgit -C pm-upstream branch one base &&\n     -+\tgit -C pm-upstream branch two base\n     -+'\n     -+\n     -+test_expect_success '--prune-merged deletes branches whose push ref is gone' '\n     -+\ttest_when_finished \"rm -rf pm-clean\" &&\n     -+\tgit clone pm-upstream pm-clean &&\n     -+\tgit -C pm-clean branch one --track origin/one &&\n     -+\tgit -C pm-clean branch two --track origin/two &&\n     -+\n     -+\tgit -C pm-clean update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-clean branch --prune-merged origin &&\n     -+\n     -+\ttest_must_fail git -C pm-clean rev-parse --verify refs/heads/one &&\n     -+\tgit -C pm-clean rev-parse --verify refs/heads/two\n     ++\tgit -C pm-upstream checkout -b next &&\n     ++\ttest_commit -C pm-upstream one-commit &&\n     ++\ttest_commit -C pm-upstream two-commit &&\n     ++\tgit -C pm-upstream branch one HEAD~ &&\n     ++\tgit -C pm-upstream branch two HEAD &&\n     ++\tgit -C pm-upstream branch wip main &&\n     ++\tgit -C pm-upstream checkout main\n      +'\n      +\n     -+test_expect_success '--prune-merged spares in-flight branches whose push ref still exists' '\n     -+\ttest_when_finished \"rm -rf pm-inflight\" &&\n     -+\tgit clone pm-upstream pm-inflight &&\n     -+\tgit -C pm-inflight branch one --track origin/one &&\n     ++test_expect_success '--prune-merged deletes branches integrated into upstream' '\n     ++\ttest_when_finished \"rm -rf pm-merged\" &&\n     ++\tgit clone pm-upstream pm-merged &&\n     ++\tgit -C pm-merged branch one one-commit &&\n     ++\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-merged branch two two-commit &&\n     ++\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n      +\n     -+\tgit -C pm-inflight branch --prune-merged origin &&\n     ++\tgit -C pm-merged branch --prune-merged origin &&\n      +\n     -+\tgit -C pm-inflight rev-parse --verify refs/heads/one\n     ++\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n     ++\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged skips branches with unpushed commits' '\n     ++test_expect_success '--prune-merged spares branches with un-integrated commits' '\n      +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n      +\tgit clone pm-upstream pm-unmerged &&\n     -+\tgit -C pm-unmerged checkout -b one --track origin/one &&\n     -+\ttest_commit -C pm-unmerged unpushed &&\n     ++\tgit -C pm-unmerged checkout -b wip origin/wip &&\n     ++\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n     ++\ttest_commit -C pm-unmerged local-only &&\n      +\tgit -C pm-unmerged checkout - &&\n      +\n     -+\tgit -C pm-unmerged update-ref -d refs/remotes/origin/one &&\n      +\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n      +\ttest_grep \"not fully merged\" err &&\n      +\ttest_grep \"Skipped 1 branch\" err &&\n      +\ttest_grep \"re-run with --force\" err &&\n      +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n     -+\tgit -C pm-unmerged rev-parse --verify refs/heads/one\n     ++\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n      +'\n      +\n     -+test_expect_success '--prune-merged --force deletes branches with unpushed commits' '\n     ++test_expect_success '--prune-merged --force deletes branches regardless of reachability' '\n      +\ttest_when_finished \"rm -rf pm-force\" &&\n      +\tgit clone pm-upstream pm-force &&\n     -+\tgit -C pm-force checkout -b one --track origin/one &&\n     -+\ttest_commit -C pm-force unpushed &&\n     ++\tgit -C pm-force checkout -b wip origin/wip &&\n     ++\tgit -C pm-force branch --set-upstream-to=origin/next wip &&\n     ++\ttest_commit -C pm-force local-only &&\n      +\tgit -C pm-force checkout - &&\n      +\n     -+\tgit -C pm-force update-ref -d refs/remotes/origin/one &&\n      +\tgit -C pm-force branch --force --prune-merged origin &&\n      +\n     -+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/one\n     ++\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/wip\n      +'\n      +\n     -+test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' '\n     -+\ttest_when_finished \"rm -rf pm-fallback\" &&\n     -+\tgit clone pm-upstream pm-fallback &&\n     -+\tgit -C pm-fallback branch one --track origin/one &&\n     -+\tgit -C pm-fallback update-ref -d refs/remotes/origin/one &&\n     -+\t# Detach HEAD to an unrelated commit so the candidate is not\n     -+\t# reachable from local HEAD. The upstream origin/one is now\n     -+\t# gone; the merged-ness check should fall back to\n     -+\t# refs/remotes/origin/HEAD, against which \"one\" is reachable.\n     -+\tgit -C pm-fallback commit --allow-empty -m unrelated &&\n     -+\tgit -C pm-fallback checkout --detach &&\n     -+\tgit -C pm-fallback reset --hard HEAD^ &&\n     -+\n     -+\tgit -C pm-fallback branch --prune-merged origin &&\n     -+\n     -+\ttest_must_fail git -C pm-fallback rev-parse --verify refs/heads/one\n     -+'\n     ++test_expect_success '--prune-merged skips branches whose upstream is gone' '\n     ++\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n     ++\tgit clone pm-upstream pm-upstream-gone &&\n     ++\tgit -C pm-upstream-gone branch one one-commit &&\n     ++\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n      +\n     -+test_expect_success '--prune-merged refuses when upstream and remote default are both gone' '\n     -+\ttest_when_finished \"rm -rf pm-both-gone\" &&\n     -+\tgit clone pm-upstream pm-both-gone &&\n     -+\tgit -C pm-both-gone checkout -b one --track origin/one &&\n     -+\ttest_commit -C pm-both-gone unpushed &&\n     -+\tgit -C pm-both-gone checkout - &&\n     ++\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n     ++\tgit -C pm-upstream-gone branch --prune-merged origin &&\n      +\n     -+\tgit -C pm-both-gone update-ref -d refs/remotes/origin/HEAD &&\n     -+\tgit -C pm-both-gone update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-both-gone branch --prune-merged origin 2>err &&\n     -+\ttest_grep \"not fully merged\" err &&\n     -+\n     -+\tgit -C pm-both-gone rev-parse --verify refs/heads/one\n     ++\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n      +'\n      +\n      +test_expect_success '--prune-merged never deletes the checked-out branch' '\n      +\ttest_when_finished \"rm -rf pm-head\" &&\n      +\tgit clone pm-upstream pm-head &&\n     -+\tgit -C pm-head checkout -b one --track origin/one &&\n     ++\tgit -C pm-head checkout -b one one-commit &&\n     ++\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-head update-ref -d refs/remotes/origin/one &&\n      +\tgit -C pm-head branch --force --prune-merged origin &&\n      +\n      +\tgit -C pm-head rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged deletes when push ref differs from upstream' '\n     -+\ttest_when_finished \"rm -rf pm-pushdiff\" &&\n     -+\tgit clone pm-upstream pm-pushdiff &&\n     -+\tgit -C pm-pushdiff config push.default current &&\n     -+\tgit -C pm-pushdiff branch --track topic-a origin/one &&\n     -+\n     -+\tgit -C pm-pushdiff branch --force --prune-merged origin &&\n     -+\n     -+\ttest_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a\n     -+'\n     -+\n      +test_expect_success '--prune-merged spares the local default branch' '\n      +\ttest_when_finished \"rm -rf pm-default\" &&\n      +\tgit clone pm-upstream pm-default &&\n     -+\tgit -C pm-default config push.default current &&\n      +\tgit -C pm-default checkout --detach &&\n     -+\tgit -C pm-default branch --prune-merged origin &&\n     ++\tgit -C pm-default branch --force --prune-merged origin &&\n      +\tgit -C pm-default rev-parse --verify refs/heads/main\n      +'\n      +\n      +test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n      +\ttest_when_finished \"rm -rf pm-default-alias\" &&\n      +\tgit clone pm-upstream pm-default-alias &&\n     -+\tgit -C pm-default-alias config push.default current &&\n      +\tgit -C pm-default-alias branch --track trunk origin/main &&\n      +\tgit -C pm-default-alias checkout --detach &&\n     -+\tgit -C pm-default-alias branch --force --prune-merged origin &&\n     ++\tgit -C pm-default-alias branch --prune-merged origin &&\n      +\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n      +\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n      +'\n     -+\n     -+test_expect_success '--prune-merged spares branches whose push ref is the default branch' '\n     -+\ttest_when_finished \"rm -rf pm-pushdefault\" &&\n     -+\tgit clone pm-upstream pm-pushdefault &&\n     -+\tgit -C pm-pushdefault branch --track topic origin/one &&\n     -+\tgit -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&\n     -+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-pushdefault update-ref -d refs/remotes/origin/main &&\n     -+\tgit -C pm-pushdefault checkout --detach &&\n     -+\tgit -C pm-pushdefault branch --prune-merged origin &&\n     -+\tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n     -+'\n      +\n       test_done\n 4:  2c3f751569 ! 4:  dd33309344 branch: add branch.<name>.pruneMerged opt-out\n     @@ Commit message\n          branch: add branch.<name>.pruneMerged opt-out\n      \n          Setting branch.<name>.pruneMerged=false exempts that branch from\n     -    --prune-merged (and from fetch --prune-merged), even with --force.\n     -    Useful for keeping a topic branch around between rounds.\n     +    --prune-merged, even with --force. Useful for keeping a topic\n     +    branch around between rounds.\n      \n          Explicit deletion via 'git branch -d' is unaffected.\n      \n     @@ Documentation/config/branch.adoc: for details).\n      +\tExplicit deletion via `git branch -d` is unaffected.\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote\n     - \tDelete the local branches that `--forked` would list for\n     - \tthe same _<remote>_ arguments, but only when the branch's\n     - \tpush destination remote-tracking branch (the branch `git push`\n     --\twould update; see `branch_get_push` semantics) no longer\n     --\tresolves locally. In other words: the branch was pushed\n     --\tunder some name on _<remote>_, and that name has since\n     --\tbeen pruned upstream.\n     -+\twould update) no longer resolves locally. In other words:\n     -+\tthe branch was pushed under some name on _<remote>_, and\n     -+\tthat name has since been pruned upstream.\n     - +\n     - As a safety check, branches with commits not yet integrated into\n     - their upstream remote-tracking branch are refused; if the upstream\n     - itself is gone, the remote's default branch is consulted instead.\n     - With `--force` (or `-f`), delete refused branches regardless. The\n     --currently checked-out branch in any worktree is always preserved.\n     -+currently checked-out branch in any worktree is always preserved,\n     -+as is any branch with `branch.<name>.pruneMerged` set to `false`.\n     +@@ Documentation/git-branch.adoc: A branch whose upstream no longer resolves locally is left alone\n     + integrated). With `--force` (or `-f`), the reachability check is\n     + skipped and every branch in the candidate set is deleted. The\n     + currently checked-out branch in any worktree is always preserved,\n     +-as is the local branch that mirrors _<remote>_'s default branch.\n     ++as is any branch with `branch.<name>.pruneMerged` set to `false`,\n     ++and the local branch that mirrors _<remote>_'s default branch.\n       \n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\tstruct strbuf full = STRBUF_INIT;\n      +\t\tstruct strbuf key = STRBUF_INIT;\n       \t\tstruct branch *branch;\n     - \t\tconst char *push_ref;\n       \t\tconst char *upstream;\n      +\t\tint opt_out = 0;\n       \n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\t}\n       \t\tstrbuf_release(&full);\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n     - \t\tif (upstream &&\n     - \t\t    string_list_has_string(&protected_default_refs, upstream)) {\n     + \t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     + \t\tif (!upstream ||\n     + \t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n     +-\t\t\t\t     upstream))\n     ++\t\t\t\t     upstream)) {\n     ++\t\t\tstrbuf_release(&key);\n     + \t\t\tcontinue;\n     ++\t\t}\n     + \t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n       \t\t\tconst char *leaf = strrchr(upstream, '/');\n      -\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n      +\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n      +\t\t\t\tstrbuf_release(&key);\n       \t\t\t\tcontinue;\n      +\t\t\t}\n     - \t\t}\n     - \n     - \t\tpush_ref = branch ? branch_get_push(branch, NULL) : NULL;\n     --\t\tif (!push_ref)\n     -+\t\tif (!push_ref) {\n     -+\t\t\tstrbuf_release(&key);\n     - \t\t\tcontinue;\n     -+\t\t}\n     - \t\tif (refs_ref_exists(get_main_ref_store(the_repository),\n     --\t\t\t\t    push_ref))\n     -+\t\t\t\t    push_ref)) {\n     -+\t\t\tstrbuf_release(&key);\n     -+\t\t\tcontinue;\n     -+\t\t}\n     -+\t\tif (string_list_has_string(&protected_default_refs, push_ref)) {\n     -+\t\t\tstrbuf_release(&key);\n     - \t\t\tcontinue;\n     --\t\tif (string_list_has_string(&protected_default_refs, push_ref))\n      +\t\t}\n      +\n      +\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n      +\t\t\tstrbuf_release(&key);\n     - \t\t\tcontinue;\n     -+\t\t}\n     ++\t\t\tcontinue;\n     + \t\t}\n      +\t\tstrbuf_release(&key);\n       \n       \t\tstrvec_push(&deletable, short_name);\n       \t}\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul\n     - \tgit -C pm-pushdefault rev-parse --verify refs/heads/topic\n     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default branch by name, no\n     + \ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n       '\n       \n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n      +\ttest_when_finished \"rm -rf pm-optout\" &&\n      +\tgit clone pm-upstream pm-optout &&\n     -+\tgit -C pm-optout branch one --track origin/one &&\n     -+\tgit -C pm-optout branch two --track origin/two &&\n     ++\tgit -C pm-optout branch one one-commit &&\n     ++\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-optout branch two two-commit &&\n     ++\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n      +\tgit -C pm-optout config branch.one.pruneMerged false &&\n      +\n     -+\tgit -C pm-optout update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-optout update-ref -d refs/remotes/origin/two &&\n      +\tgit -C pm-optout branch --prune-merged origin 2>err &&\n      +\n      +\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose pus\n      +test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n      +\ttest_when_finished \"rm -rf pm-optout-force\" &&\n      +\tgit clone pm-upstream pm-optout-force &&\n     -+\tgit -C pm-optout-force checkout -b one --track origin/one &&\n     -+\ttest_commit -C pm-optout-force unpushed &&\n     ++\tgit -C pm-optout-force checkout -b wip origin/wip &&\n     ++\tgit -C pm-optout-force branch --set-upstream-to=origin/next wip &&\n     ++\ttest_commit -C pm-optout-force local-only &&\n      +\tgit -C pm-optout-force checkout - &&\n     -+\tgit -C pm-optout-force config branch.one.pruneMerged false &&\n     ++\tgit -C pm-optout-force config branch.wip.pruneMerged false &&\n      +\n     -+\tgit -C pm-optout-force update-ref -d refs/remotes/origin/one &&\n      +\tgit -C pm-optout-force branch --force --prune-merged origin &&\n      +\n     -+\tgit -C pm-optout-force rev-parse --verify refs/heads/one\n     ++\tgit -C pm-optout-force rev-parse --verify refs/heads/wip\n      +'\n      +\n      +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n      +\ttest_when_finished \"rm -rf pm-optout-d\" &&\n      +\tgit clone pm-upstream pm-optout-d &&\n     -+\tgit -C pm-optout-d branch one --track origin/one &&\n     ++\tgit -C pm-optout-d branch one one-commit &&\n     ++\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n      +\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n      +\n      +\tgit -C pm-optout-d branch -d one &&\n 5:  f79707ce7c ! 5:  6e81ed3147 branch: add --all-remotes flag\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete refused branches regardless. The\n     - currently checked-out branch in any worktree is always preserved,\n     - as is any branch with `branch.<name>.pruneMerged` set to `false`.\n     +@@ Documentation/git-branch.adoc: currently checked-out branch in any worktree is always preserved,\n     + as is any branch with `branch.<name>.pruneMerged` set to `false`,\n     + and the local branch that mirrors _<remote>_'s default branch.\n       \n      +`--all-remotes`::\n      +\tWith `--forked` or `--prune-merged`, act on every\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n       '\n       \n      +test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n     -+\ttest_when_finished \"rm -rf pm-allremotes\" &&\n     ++\ttest_when_finished \"rm -rf pm-allremotes pm-other\" &&\n      +\tgit clone pm-upstream pm-allremotes &&\n      +\ttest_create_repo pm-other &&\n      +\ttest_commit -C pm-other other-base &&\n     -+\tgit -C pm-other branch foreign other-base &&\n     ++\tgit -C pm-other checkout -b stable &&\n     ++\ttest_commit -C pm-other foreign-commit &&\n     ++\tgit -C pm-other branch foreign HEAD &&\n     ++\tgit -C pm-other checkout main &&\n     ++\n      +\tgit -C pm-allremotes remote add other ../pm-other &&\n      +\tgit -C pm-allremotes fetch other &&\n     -+\tgit -C pm-allremotes branch one --track origin/one &&\n     -+\tgit -C pm-allremotes branch foreign --track other/foreign &&\n     ++\tgit -C pm-allremotes branch one one-commit &&\n     ++\tgit -C pm-allremotes branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-allremotes branch foreign other/foreign &&\n     ++\tgit -C pm-allremotes branch --set-upstream-to=other/stable foreign &&\n      +\n     -+\tgit -C pm-allremotes update-ref -d refs/remotes/origin/one &&\n     -+\tgit -C pm-allremotes update-ref -d refs/remotes/other/foreign &&\n     -+\tgit -C pm-allremotes branch --force --prune-merged --all-remotes &&\n     ++\tgit -C pm-allremotes branch --prune-merged --all-remotes &&\n      +\n      +\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n\n-- \ngitgitgadget\n"},{"id":"543198","messageId":"22fa8515df4058875a7a6f53e4566493aaf193cf.1778605658.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v8 1/5] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:34Z","receivedAt":"2026-05-12T17:07:44Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543199","messageId":"b443f0f3676f0382929df0ab95ba7faf0c80193a.1778605658.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v8 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:35Z","receivedAt":"2026-05-12T17:07:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543200","messageId":"3032e9c39a13a1826c24c3d2f2b229dab932439d.1778605658.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v8 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:36Z","receivedAt":"2026-05-12T17:07:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list, but\nonly those whose tip is reachable from their configured upstream\nremote-tracking branch (branch.<name>.merge): the work has already\nlanded on the upstream it tracks, so the local copy is no longer\nneeded.\n\nA branch whose upstream no longer resolves locally is left alone --\nits disappearance is not, on its own, evidence that the work was\nintegrated. With --force, skip the reachability check and delete\nevery branch in the candidate set. The currently checked-out\nbranch in any worktree is always preserved, as is the local branch\nthat mirrors <remote>'s default branch.\n\nReachability is read from whatever the remote-tracking refs say\nlocally, so the natural workflow is\n\n\tgit fetch <remote>\n\tgit branch --prune-merged <remote>\n\nwith no implicit cleanup driven by fetch itself.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  20 +++++\n builtin/branch.c              | 144 +++++++++++++++++++++++++++++-----\n t/t3200-branch.sh             |  96 +++++++++++++++++++++++\n 3 files changed, 241 insertions(+), 19 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..c3f5150f03 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch [-f] --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,25 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only those whose tip is\n+\treachable from their configured upstream remote-tracking\n+\tbranch (`branch.<name>.merge`). In other words: the work on\n+\tthe branch has already landed on the upstream it tracks, so\n+\tthe local copy is no longer needed.\n++\n+Run `git fetch` first so the upstream remote-tracking branches\n+reflect the current state of _<remote>_; reachability is checked\n+against whatever the remote-tracking refs say locally.\n++\n+A branch whose upstream no longer resolves locally is left alone\n+(its disappearance is not, on its own, evidence that the work was\n+integrated). With `--force` (or `-f`), the reachability check is\n+skipped and every branch in the candidate set is deleted. The\n+currently checked-out branch in any worktree is always preserved,\n+as is the local branch that mirrors _<remote>_'s default branch.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..50bf9774a8 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,\n \t * any of the following code, but during the transition period,\n \t * a gentle reminder is in order.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n+static int delete_branches(int argc, const char **argv,\n+\t\t\t   int no_head_fallback,\n+\t\t\t   int force, int kinds,\n \t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n@@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n-\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n+\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n \t\t\tif (!warn_only)\n \t\t\t\tret = 1;\n \t\t\tgoto next;\n@@ -753,36 +756,132 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int force,\n+\t\t\t\t int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream ||\n+\t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t     upstream))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      1, force,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +924,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +980,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1026,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1036,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -971,12 +1074,15 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..ca071338d3 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,100 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares branches with un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"re-run with --force\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged --force deletes branches regardless of reachability' '\n+\ttest_when_finished \"rm -rf pm-force\" &&\n+\tgit clone pm-upstream pm-force &&\n+\tgit -C pm-force checkout -b wip origin/wip &&\n+\tgit -C pm-force branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-force local-only &&\n+\tgit -C pm-force checkout - &&\n+\n+\tgit -C pm-force branch --force --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged origin &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --force --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --force --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543201","messageId":"dd333093442dc2b9ffafc761d6ca23f94fe9ec1f.1778605658.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v8 4/5] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:37Z","receivedAt":"2026-05-12T17:07:49Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged, even with --force. Useful for keeping a topic\nbranch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 ++++++\n Documentation/git-branch.adoc    |  3 ++-\n builtin/branch.c                 | 23 ++++++++++++++++--\n t/t3200-branch.sh                | 41 ++++++++++++++++++++++++++++++++\n 4 files changed, 71 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..4662ef35c1 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c3f5150f03..080cdc218a 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -229,7 +229,8 @@ A branch whose upstream no longer resolves locally is left alone\n integrated). With `--force` (or `-f`), the reachability check is\n skipped and every branch in the candidate set is deleted. The\n currently checked-out branch in any worktree is always preserved,\n-as is the local branch that mirrors _<remote>_'s default branch.\n+as is any branch with `branch.<name>.pruneMerged` set to `false`,\n+and the local branch that mirrors _<remote>_'s default branch.\n \n `-v`::\n `-vv`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 50bf9774a8..2969780210 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -836,12 +836,15 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *upstream;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -850,13 +853,29 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n \t\tif (!upstream ||\n \t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t     upstream))\n+\t\t\t\t     upstream)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n \t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex ca071338d3..8e877862f5 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1867,4 +1867,45 @@ test_expect_success '--prune-merged protects only the default branch by name, no\n \ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n+\tgit clone pm-upstream pm-optout-force &&\n+\tgit -C pm-optout-force checkout -b wip origin/wip &&\n+\tgit -C pm-optout-force branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-optout-force local-only &&\n+\tgit -C pm-optout-force checkout - &&\n+\tgit -C pm-optout-force config branch.wip.pruneMerged false &&\n+\n+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n+\n+\tgit -C pm-optout-force rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543202","messageId":"6e81ed3147ac388aadd7e4034604f3be07c576eb.1778605658.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v8 5/5] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-12T17:07:38Z","receivedAt":"2026-05-12T17:07:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 +++++--\n builtin/branch.c              | 41 ++++++++++++++++++++++----------\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 80 insertions(+), 14 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 080cdc218a..bf59f4852d 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch [-f] --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -232,6 +232,11 @@ currently checked-out branch in any worktree is always preserved,\n as is any branch with `branch.<name>.pruneMerged` set to `false`,\n and the local branch that mirrors _<remote>_'s default branch.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2969780210..081a1a1467 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -687,6 +687,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -776,7 +783,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -789,6 +796,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -802,15 +811,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -818,8 +827,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int force,\n-\t\t\t\t int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int force, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -828,10 +837,11 @@ static int prune_merged_branches(int argc, const char **argv, int force,\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -944,6 +954,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1001,6 +1012,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1044,6 +1058,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1097,10 +1114,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, force, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 8e877862f5..e93e93654e 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1908,4 +1929,27 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes pm-other\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other checkout -b stable &&\n+\ttest_commit -C pm-other foreign-commit &&\n+\tgit -C pm-other branch foreign HEAD &&\n+\tgit -C pm-other checkout main &&\n+\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one one-commit &&\n+\tgit -C pm-allremotes branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-allremotes branch foreign other/foreign &&\n+\tgit -C pm-allremotes branch --set-upstream-to=other/stable foreign &&\n+\n+\tgit -C pm-allremotes branch --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543240","messageId":"xmqqqznfifrg.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"Re: [PATCH v8 0/5] branch: prune-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-13T13:46:27Z","receivedAt":"2026-05-13T13:46:29Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n>      +    Delete the local branches that --forked <remote> would list, but\n>      +    only those whose tip is reachable from their configured upstream\n>      +    remote-tracking branch (branch.<name>.merge): the work has already\n>      +    landed on the upstream it tracks, so the local copy is no longer\n>      +    needed.\n>      +\n>      +    A branch whose upstream no longer resolves locally is left alone --\n>      +    its disappearance is not, on its own, evidence that the work was\n>      +    integrated.\n\nThat matches my understanding of the original motivation of this\ntopic a lot better than the previous round.\n\n>      +    integrated. With --force, skip the reachability check and delete\n>      +    every branch in the candidate set.\n\nI am not sure if this is a good idea at all.  The option is called\nprune-MERGED and with or without --force, mergedness should be what\ndetermines if a branch is deleted.\n\nTo perform an equivalent of\n\n\t$ git branch -D $(git branch --forked <remote>)\n\nit would be better not to (ab)use the more commonly useful and much\nsafer \"--prune-merged\", and let's not add \"--prune-forked\" either as\na short-cut.  A nuclear option should be made harder to trigger, ot\neasier to trigger by confusion between \"--prune-{merged,forked}\".\n"},{"id":"543260","messageId":"20260513185717.51743-1-haraldnordgren@gmail.com","threadId":"65574","inReplyTo":"xmqqqznfifrg.fsf@gitster.g","subject":"[PATCH] fetch: add fetch.pruneLocalBranches config","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-13T18:57:17Z","receivedAt":"2026-05-13T18:57:21Z","isPatch":true,"body":"> I am not sure if this is a good idea at all.  The option is called\n> prune-MERGED and with or without --force, mergedness should be what\n> determines if a branch is deleted.\n\nWell, when I started writing the feature it was \"prune local branches\",\nand it evolved from there to prune merged.\n\nBut you're probably right. I did wipe up some branches with real work on my\nside using this (I restored them), so it seems to be more of a foot-gun\nthan I first imagined.\n\nSeems reasonable to remove the '--force' functionality.\n\n\nHarald\n"},{"id":"543263","messageId":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v8.git.git.1778605658.gitgitgadget@gmail.com","subject":"[PATCH v9 0/5] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:38Z","receivedAt":"2026-05-13T19:34:45Z","isPatch":true,"body":" * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nHarald Nordgren (5):\n  branch: add --forked <remote>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: add --prune-merged <remote>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --all-remotes flag\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  37 ++++\n builtin/branch.c                 | 292 +++++++++++++++++++++++++++++--\n t/t3200-branch.sh                | 208 ++++++++++++++++++++++\n 4 files changed, 528 insertions(+), 16 deletions(-)\n\n\nbase-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v9\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v9\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v8:\n\n 1:  22fa8515df = 1:  9324b26091 branch: add --forked <remote>\n 2:  b443f0f367 = 2:  2a13e5d4bc branch: let delete_branches warn instead of error on bulk refusal\n 3:  3032e9c39a ! 3:  f87e96e99d branch: add --prune-merged <remote>\n     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n       git branch --forked <remote>...\n     -+git branch [-f] --prune-merged <remote>...\n     ++git branch --prune-merged <remote>...\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi\n      ++\n      +A branch whose upstream no longer resolves locally is left alone\n      +(its disappearance is not, on its own, evidence that the work was\n     -+integrated). With `--force` (or `-f`), the reachability check is\n     -+skipped and every branch in the candidate set is deleted. The\n     -+currently checked-out branch in any worktree is always preserved,\n     -+as is the local branch that mirrors _<remote>_'s default branch.\n     ++integrated). The currently checked-out branch in any worktree is\n     ++always preserved, as is the local branch that mirrors _<remote>_'s\n     ++default branch.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n       \treturn 0;\n       }\n       \n     -+static int prune_merged_branches(int argc, const char **argv, int force,\n     -+\t\t\t\t int quiet)\n     ++static int prune_merged_branches(int argc, const char **argv, int quiet)\n      +{\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n      +\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n     -+\t\t\t\t      1, force,\n     ++\t\t\t\t      1, 0,\n      +\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n      +\t\t\t\t      1, &n_not_merged);\n      +\n      +\tif (n_not_merged && !quiet)\n      +\t\tfprintf(stderr,\n      +\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n     -+\t\t\t   \"re-run with --force to delete it anyway.\\n\",\n     ++\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n      +\t\t\t   \"Skipped %d branches that are not fully merged; \"\n     -+\t\t\t   \"re-run with --force to delete them anyway.\\n\",\n     ++\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n      +\t\t\t   n_not_merged),\n      +\t\t\tn_not_merged);\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tret = list_forked_branches(argc, argv);\n       \t\tgoto out;\n      +\t} else if (prune_merged) {\n     -+\t\tret = prune_merged_branches(argc, argv, force, quiet);\n     ++\t\tret = prune_merged_branches(argc, argv, quiet);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n      +\ttest_grep \"not fully merged\" err &&\n      +\ttest_grep \"Skipped 1 branch\" err &&\n     -+\ttest_grep \"re-run with --force\" err &&\n     ++\ttest_grep \"git branch -D\" err &&\n      +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n      +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n      +'\n      +\n     -+test_expect_success '--prune-merged --force deletes branches regardless of reachability' '\n     -+\ttest_when_finished \"rm -rf pm-force\" &&\n     -+\tgit clone pm-upstream pm-force &&\n     -+\tgit -C pm-force checkout -b wip origin/wip &&\n     -+\tgit -C pm-force branch --set-upstream-to=origin/next wip &&\n     -+\ttest_commit -C pm-force local-only &&\n     -+\tgit -C pm-force checkout - &&\n     -+\n     -+\tgit -C pm-force branch --force --prune-merged origin &&\n     -+\n     -+\ttest_must_fail git -C pm-force rev-parse --verify refs/heads/wip\n     -+'\n     -+\n      +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n      +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n      +\tgit clone pm-upstream pm-upstream-gone &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\tgit -C pm-head checkout -b one one-commit &&\n      +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-head branch --force --prune-merged origin &&\n     ++\tgit -C pm-head branch --prune-merged origin &&\n      +\n      +\tgit -C pm-head rev-parse --verify refs/heads/one\n      +'\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_when_finished \"rm -rf pm-default\" &&\n      +\tgit clone pm-upstream pm-default &&\n      +\tgit -C pm-default checkout --detach &&\n     -+\tgit -C pm-default branch --force --prune-merged origin &&\n     ++\tgit -C pm-default branch --prune-merged origin &&\n      +\tgit -C pm-default rev-parse --verify refs/heads/main\n      +'\n      +\n 4:  dd33309344 ! 4:  19b6d94fa7 branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/config/branch.adoc: for details).\n      +\tExplicit deletion via `git branch -d` is unaffected.\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: A branch whose upstream no longer resolves locally is left alone\n     - integrated). With `--force` (or `-f`), the reachability check is\n     - skipped and every branch in the candidate set is deleted. The\n     - currently checked-out branch in any worktree is always preserved,\n     --as is the local branch that mirrors _<remote>_'s default branch.\n     -+as is any branch with `branch.<name>.pruneMerged` set to `false`,\n     -+and the local branch that mirrors _<remote>_'s default branch.\n     +@@ Documentation/git-branch.adoc: against whatever the remote-tracking refs say locally.\n     + A branch whose upstream no longer resolves locally is left alone\n     + (its disappearance is not, on its own, evidence that the work was\n     + integrated). The currently checked-out branch in any worktree is\n     +-always preserved, as is the local branch that mirrors _<remote>_'s\n     ++always preserved, as is any branch with `branch.<name>.pruneMerged`\n     ++set to `false`, and the local branch that mirrors _<remote>_'s\n     + default branch.\n       \n       `-v`::\n     - `-vv`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n       \tfor_each_string_list_item(item, &candidates) {\n       \t\tconst char *short_name = item->string;\n       \t\tstruct strbuf full = STRBUF_INIT;\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\t\tcontinue;\n       \t\t}\n       \t\tstrbuf_release(&full);\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n       \t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n       \t\tif (!upstream ||\n       \t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default\n      +\ttest_grep \"Skipping .one.\" err\n      +'\n      +\n     -+test_expect_success '--prune-merged --force still honours pruneMerged=false' '\n     -+\ttest_when_finished \"rm -rf pm-optout-force\" &&\n     -+\tgit clone pm-upstream pm-optout-force &&\n     -+\tgit -C pm-optout-force checkout -b wip origin/wip &&\n     -+\tgit -C pm-optout-force branch --set-upstream-to=origin/next wip &&\n     -+\ttest_commit -C pm-optout-force local-only &&\n     -+\tgit -C pm-optout-force checkout - &&\n     -+\tgit -C pm-optout-force config branch.wip.pruneMerged false &&\n     -+\n     -+\tgit -C pm-optout-force branch --force --prune-merged origin &&\n     -+\n     -+\tgit -C pm-optout-force rev-parse --verify refs/heads/wip\n     -+'\n     -+\n      +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n      +\ttest_when_finished \"rm -rf pm-optout-d\" &&\n      +\tgit clone pm-upstream pm-optout-d &&\n 5:  6e81ed3147 ! 5:  6ae95d3f98 branch: add --all-remotes flag\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n      -git branch --forked <remote>...\n     --git branch [-f] --prune-merged <remote>...\n     +-git branch --prune-merged <remote>...\n      +git branch --forked (<remote>... | --all-remotes)\n     -+git branch [-f] --prune-merged (<remote>... | --all-remotes)\n     ++git branch --prune-merged (<remote>... | --all-remotes)\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: currently checked-out branch in any worktree is always preserved,\n     - as is any branch with `branch.<name>.pruneMerged` set to `false`,\n     - and the local branch that mirrors _<remote>_'s default branch.\n     +@@ Documentation/git-branch.adoc: always preserved, as is any branch with `branch.<name>.pruneMerged`\n     + set to `false`, and the local branch that mirrors _<remote>_'s\n     + default branch.\n       \n      +`--all-remotes`::\n      +\tWith `--forked` or `--prune-merged`, act on every\n     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n       \treturn 0;\n       }\n       \n     --static int prune_merged_branches(int argc, const char **argv, int force,\n     --\t\t\t\t int quiet)\n     +-static int prune_merged_branches(int argc, const char **argv, int quiet)\n      +static int prune_merged_branches(int argc, const char **argv,\n     -+\t\t\t\t int all_remotes, int force, int quiet)\n     ++\t\t\t\t int all_remotes, int quiet)\n       {\n       \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n       \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n       \tint n_not_merged = 0;\n       \tint ret = 0;\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \n      +\tif (all_remotes && !forked && !prune_merged)\n      +\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n     ++\n      +\n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n       \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n     @@ builtin/branch.c: int cmd_branch(int argc,\n      +\t\tret = list_forked_branches(argc, argv, all_remotes);\n       \t\tgoto out;\n       \t} else if (prune_merged) {\n     --\t\tret = prune_merged_branches(argc, argv, force, quiet);\n     -+\t\tret = prune_merged_branches(argc, argv, all_remotes, force, quiet);\n     +-\t\tret = prune_merged_branches(argc, argv, quiet);\n     ++\t\tret = prune_merged_branches(argc, argv, all_remotes, quiet);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n\n-- \ngitgitgadget\n"},{"id":"543264","messageId":"9324b26091920502026cf330ca4d84b957c1c16c.1778700883.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v9 1/5] branch: add --forked <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:39Z","receivedAt":"2026-05-13T19:34:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream falls within any of\nthe given <remote> arguments. <remote> may be either a configured\nremote name (matching all of its remote-tracking branches) or a\nsingle remote-tracking branch. Multiple <remote> arguments are\nunioned.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 ++++\n builtin/branch.c              | 110 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  54 +++++++++++++++++\n 3 files changed, 174 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..5773104cd3 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <remote>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,17 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches that fork from any of the given _<remote>_\n+\targuments, that is, those whose configured upstream\n+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n+\tbranches.\n++\n+Each _<remote>_ may be either the name of a configured remote\n+(e.g. `origin`, meaning any branch tracking a\n+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..b3289a8875 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <remote>...\"),\n \tNULL\n };\n \n@@ -673,6 +674,105 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *remote_names,\n+\t\t\t      struct string_list *tracking_refs)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct remote *remote;\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\n+\t\tremote = remote_get(arg);\n+\t\tif (remote && remote_is_configured(remote, 0)) {\n+\t\t\tstring_list_insert(remote_names, remote->name);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n+\t\t\tstring_list_insert(tracking_refs, full_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n+\t\t      \"remote-tracking branch\"), arg);\n+\t}\n+}\n+\n+static int branch_is_forked(const char *short_name,\n+\t\t\t    const struct string_list *remote_names,\n+\t\t\t    const struct string_list *tracking_refs)\n+{\n+\tstruct branch *branch = branch_get(short_name);\n+\tconst char *upstream;\n+\n+\tif (!branch || !branch->remote_name)\n+\t\treturn 0;\n+\n+\tif (string_list_has_string(remote_names, branch->remote_name))\n+\t\treturn 1;\n+\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n+\t\treturn 1;\n+\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *remote_names;\n+\tconst struct string_list *tracking_refs;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb = {\n+\t\t.remote_names = &remote_names,\n+\t\t.tracking_refs = &tracking_refs,\n+\t\t.out = &out,\n+\t};\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&remote_names, 0);\n+\tstring_list_clear(&tracking_refs, 0);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +814,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +868,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +914,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +923,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +963,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..24a3ec44ee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,58 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached\n+'\n+\n+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n+\tgit -C forked branch --forked origin >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <remote> arguments' '\n+\tgit -C forked branch --forked origin/one other >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown remote/ref' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--forked requires at least one <remote>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <remote>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543265","messageId":"2a13e5d4bcd9a1cec616ef85f4f407d211669078.1778700883.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v9 2/5] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:40Z","receivedAt":"2026-05-13T19:34:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd two new parameters to delete_branches() and the helper\ncheck_branch_commit():\n\n* warn_only switches the per-branch refusal from a hard error\n  (\"error: the branch 'X' is not fully merged\" plus a four-line\n  hint about 'git branch -D X') to a one-line warning, and\n  causes the function to skip those branches without setting its\n  exit code. Each refused branch is still skipped from deletion.\n* n_not_merged, when non-NULL, is incremented for each branch\n  refused on the not-merged path, so a bulk caller can summarize\n  rather than print per-branch advice.\n\nAll existing call sites pass 0 / NULL and so are unaffected. Both\nparameters are wired up so a bulk-deletion caller can suppress\nthe noise normally appropriate for a one-shot 'git branch -d'.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 29 ++++++++++++++++++++---------\n 1 file changed, 20 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b3289a8875..1941f8a9ad 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +201,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +228,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +318,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -961,7 +971,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543266","messageId":"19b6d94fa7ddb1d9907b36d2b6b6d6b44b089870.1778700883.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v9 4/5] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:42Z","receivedAt":"2026-05-13T19:34:50Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n--prune-merged, even with --force. Useful for keeping a topic\nbranch around between rounds.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  3 ++-\n builtin/branch.c                 | 23 +++++++++++++++++++++--\n t/t3200-branch.sh                | 27 +++++++++++++++++++++++++++\n 4 files changed, 57 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..4662ef35c1 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.\n+\tUseful for topic branches you intend to develop further after\n+\tan initial round has been merged upstream. Defaults to true.\n+\tExplicit deletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 375a0a68da..7435d38447 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -227,7 +227,8 @@ against whatever the remote-tracking refs say locally.\n A branch whose upstream no longer resolves locally is left alone\n (its disappearance is not, on its own, evidence that the work was\n integrated). The currently checked-out branch in any worktree is\n-always preserved, as is the local branch that mirrors _<remote>_'s\n+always preserved, as is any branch with `branch.<name>.pruneMerged`\n+set to `false`, and the local branch that mirrors _<remote>_'s\n default branch.\n \n `-v`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 6fe2ffd7e8..bc4f4a4a18 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -835,12 +835,15 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tstruct branch *branch;\n \t\tconst char *upstream;\n+\t\tint opt_out = 0;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tif (branch_checked_out(full.buf)) {\n \t\t\tstrbuf_release(&full);\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n \t\t}\n \t\tstrbuf_release(&full);\n@@ -849,13 +852,29 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n \t\tif (!upstream ||\n \t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n-\t\t\t\t     upstream))\n+\t\t\t\t     upstream)) {\n+\t\t\tstrbuf_release(&key);\n \t\t\tcontinue;\n+\t\t}\n \t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n \t\t\tconst char *leaf = strrchr(upstream, '/');\n-\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n+\t\t\t\tstrbuf_release(&key);\n \t\t\t\tcontinue;\n+\t\t\t}\n+\t\t}\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n \t\t}\n+\t\tstrbuf_release(&key);\n \n \t\tstrvec_push(&deletable, short_name);\n \t}\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 94ea493aee..885a275e36 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1854,4 +1854,31 @@ test_expect_success '--prune-merged protects only the default branch by name, no\n \ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543267","messageId":"6ae95d3f98212ca449cb81d3cfe332e78b8011ea.1778700883.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v9 5/5] branch: add --all-remotes flag","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:43Z","receivedAt":"2026-05-13T19:34:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nCombined with --forked or --prune-merged, --all-remotes acts on\nevery configured remote, in addition to any explicit <remote>\narguments. Used alone, it errors out.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  9 +++++--\n builtin/branch.c              | 41 +++++++++++++++++++++++---------\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 81 insertions(+), 13 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 7435d38447..3cf3bf033f 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,8 +24,8 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --forked <remote>...\n-git branch --prune-merged <remote>...\n+git branch --forked (<remote>... | --all-remotes)\n+git branch --prune-merged (<remote>... | --all-remotes)\n \n DESCRIPTION\n -----------\n@@ -231,6 +231,11 @@ always preserved, as is any branch with `branch.<name>.pruneMerged`\n set to `false`, and the local branch that mirrors _<remote>_'s\n default branch.\n \n+`--all-remotes`::\n+\tWith `--forked` or `--prune-merged`, act on every\n+\tconfigured remote in addition to any explicit _<remote>_\n+\targuments.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex bc4f4a4a18..7d45bada45 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -687,6 +687,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int collect_remote_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *remote_names = cb_data;\n+\tstring_list_insert(remote_names, remote->name);\n+\treturn 0;\n+}\n+\n static void parse_forked_args(int argc, const char **argv,\n \t\t\t      struct string_list *remote_names,\n \t\t\t      struct string_list *tracking_refs)\n@@ -776,7 +783,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n \t}\n }\n \n-static void collect_forked_set(int argc, const char **argv,\n+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n \t\t\t       struct string_list *protected_default_refs,\n \t\t\t       struct string_list *out)\n {\n@@ -789,6 +796,8 @@ static void collect_forked_set(int argc, const char **argv,\n \t};\n \n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n+\tif (all_remotes)\n+\t\tfor_each_remote(collect_remote_name, &remote_names);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n@@ -802,15 +811,15 @@ static void collect_forked_set(int argc, const char **argv,\n \tstring_list_clear(&tracking_refs, 0);\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n {\n \tstruct string_list out = STRING_LIST_INIT_DUP;\n \tstruct string_list_item *item;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, NULL, &out);\n+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n@@ -818,7 +827,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int all_remotes, int quiet)\n {\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n@@ -827,10 +837,11 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \tint n_not_merged = 0;\n \tint ret = 0;\n \n-\tif (!argc)\n-\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\tif (!argc && !all_remotes)\n+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n \n-\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n+\t\t\t   &candidates);\n \n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n@@ -943,6 +954,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint all_remotes = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1000,6 +1012,9 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n+\t\t\tPARSE_OPT_NONEG),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1043,6 +1058,10 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (all_remotes && !forked && !prune_merged)\n+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n+\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1096,10 +1115,10 @@ int cmd_branch(int argc,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n-\t\tret = list_forked_branches(argc, argv);\n+\t\tret = list_forked_branches(argc, argv, all_remotes);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tret = prune_merged_branches(argc, argv, all_remotes, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 885a275e36..a36e5ee80a 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--forked --all-remotes covers every configured remote' '\n+\tgit -C forked branch --forked --all-remotes >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n+'\n+\n+test_expect_success '--all-remotes alone is rejected' '\n+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n+\ttest_grep \"requires --forked or --prune-merged\" err\n+'\n+\n test_expect_success '--prune-merged: setup' '\n \ttest_create_repo pm-upstream &&\n \ttest_commit -C pm-upstream base &&\n@@ -1881,4 +1902,27 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n+\ttest_when_finished \"rm -rf pm-allremotes pm-other\" &&\n+\tgit clone pm-upstream pm-allremotes &&\n+\ttest_create_repo pm-other &&\n+\ttest_commit -C pm-other other-base &&\n+\tgit -C pm-other checkout -b stable &&\n+\ttest_commit -C pm-other foreign-commit &&\n+\tgit -C pm-other branch foreign HEAD &&\n+\tgit -C pm-other checkout main &&\n+\n+\tgit -C pm-allremotes remote add other ../pm-other &&\n+\tgit -C pm-allremotes fetch other &&\n+\tgit -C pm-allremotes branch one one-commit &&\n+\tgit -C pm-allremotes branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-allremotes branch foreign other/foreign &&\n+\tgit -C pm-allremotes branch --set-upstream-to=other/stable foreign &&\n+\n+\tgit -C pm-allremotes branch --prune-merged --all-remotes &&\n+\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543268","messageId":"f87e96e99d64c48bd92afecf3a6a819d36e56f6c.1778700883.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-13T19:34:41Z","receivedAt":"2026-05-13T19:34:53Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nDelete the local branches that --forked <remote> would list, but\nonly those whose tip is reachable from their configured upstream\nremote-tracking branch (branch.<name>.merge): the work has already\nlanded on the upstream it tracks, so the local copy is no longer\nneeded.\n\nA branch whose upstream no longer resolves locally is left alone --\nits disappearance is not, on its own, evidence that the work was\nintegrated. With --force, skip the reachability check and delete\nevery branch in the candidate set. The currently checked-out\nbranch in any worktree is always preserved, as is the local branch\nthat mirrors <remote>'s default branch.\n\nReachability is read from whatever the remote-tracking refs say\nlocally, so the natural workflow is\n\n\tgit fetch <remote>\n\tgit branch --prune-merged <remote>\n\nwith no implicit cleanup driven by fetch itself.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  19 +++++\n builtin/branch.c              | 143 +++++++++++++++++++++++++++++-----\n t/t3200-branch.sh             |  83 ++++++++++++++++++++\n 3 files changed, 226 insertions(+), 19 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5773104cd3..375a0a68da 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <remote>...\n+git branch --prune-merged <remote>...\n \n DESCRIPTION\n -----------\n@@ -211,6 +212,24 @@ Each _<remote>_ may be either the name of a configured remote\n `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<remote>_ arguments, but only those whose tip is\n+\treachable from their configured upstream remote-tracking\n+\tbranch (`branch.<name>.merge`). In other words: the work on\n+\tthe branch has already landed on the upstream it tracks, so\n+\tthe local copy is no longer needed.\n++\n+Run `git fetch` first so the upstream remote-tracking branches\n+reflect the current state of _<remote>_; reachability is checked\n+against whatever the remote-tracking refs say locally.\n++\n+A branch whose upstream no longer resolves locally is left alone\n+(its disappearance is not, on its own, evidence that the work was\n+integrated). The currently checked-out branch in any worktree is\n+always preserved, as is the local branch that mirrors _<remote>_'s\n+default branch.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1941f8a9ad..6fe2ffd7e8 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,\n \t * any of the following code, but during the transition period,\n \t * a gentle reminder is in order.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n+static int delete_branches(int argc, const char **argv,\n+\t\t\t   int no_head_fallback,\n+\t\t\t   int force, int kinds,\n \t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n@@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t}\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n-\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n+\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n \t\t\tif (!warn_only)\n \t\t\t\tret = 1;\n \t\t\tgoto next;\n@@ -753,36 +756,131 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_default_branch_refs(const struct string_list *remote_names,\n+\t\t\t\t\tstruct string_list *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list_item *item;\n+\n+\tfor_each_string_list_item(item, remote_names) {\n+\t\tstruct strbuf head = STRBUF_INIT;\n+\t\tconst char *target;\n+\n+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t NULL, NULL);\n+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n+\t\t\tstring_list_insert(out, target);\n+\t\tstrbuf_release(&head);\n+\t}\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *protected_default_refs,\n+\t\t\t       struct string_list *out)\n {\n \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb = {\n \t\t.remote_names = &remote_names,\n \t\t.tracking_refs = &tracking_refs,\n-\t\t.out = &out,\n+\t\t.out = out,\n \t};\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <remote>\"));\n-\n \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n-\tfor_each_string_list_item(item, &out)\n-\t\tputs(item->string);\n+\tstring_list_sort(out);\n+\n+\tif (protected_default_refs)\n+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n \n \tstring_list_clear(&remote_names, 0);\n \tstring_list_clear(&tracking_refs, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, NULL, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int quiet)\n+{\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n+\n+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(full.buf)) {\n+\t\t\tstrbuf_release(&full);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&full);\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream ||\n+\t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n+\t\t\t\t     upstream))\n+\t\t\tcontinue;\n+\t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n+\t\t\tconst char *leaf = strrchr(upstream, '/');\n+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n+\t\t\t\tcontinue;\n+\t\t}\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      1, 0,\n+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\tstring_list_clear(&protected_default_refs, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -825,6 +923,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +979,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -924,7 +1025,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -933,7 +1035,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -971,12 +1073,15 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n \t\t\t\t      quiet, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 24a3ec44ee..94ea493aee 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1771,4 +1771,87 @@ test_expect_success '--forked requires at least one <remote>' '\n \ttest_grep \"at least one <remote>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged origin &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged spares branches with un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"git branch -D\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged origin &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged origin &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged origin &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --prune-merged origin &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543505","messageId":"CAHwyqnV3K+nhPoo6XMqYqT3FUkXge_DU=i34gzdEOjzVN6C0NA@mail.gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"Re: [PATCH v9 0/5] branch: prune-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-18T08:14:49Z","receivedAt":"2026-05-18T08:15:29Z","isPatch":true,"body":"Can I get some reviews here please? It's a bit less useless after\nremoving the nuclear option, but also much less of a foot-gun.\n\nHarald\n"},{"id":"543535","messageId":"6501a3d5-a5ec-421b-8526-ee7d4ae5ea98@gmail.com","threadId":"65574","inReplyTo":"f87e96e99d64c48bd92afecf3a6a819d36e56f6c.1778700883.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-18T15:27:01Z","receivedAt":"2026-05-18T15:27:04Z","isPatch":true,"body":"Hi Harald\n\nOn 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Delete the local branches that --forked <remote> would list, but\n> only those whose tip is reachable from their configured upstream\n> remote-tracking branch (branch.<name>.merge): the work has already\n> landed on the upstream it tracks, so the local copy is no longer\n> needed.\n\nI think being able to prune branches that have been merged into their \nupstream is a good idea. However, I find the focus on remotes rather \nthan upstream branches in the UI a bit confusing. While the upstream of \na branch is often a remote tracking branch it doesn't have to be. For my \npersonal projects I often do\n\n\tgit checkout -b topic master\n\nand it would be nice to be able to run\n\n\tgit branch --prune-merged master\n\nto clean up those topics that have been merged. Similarly I think it is \nconfusing that\n\n\tgit checkout -b topic origin\n\nstarts a branch from the default branch on origin, but if I run\n\n\tgit branch --prune-merged origin\n\nto clean it up, it will clean up all the branches with an upstream on \norigin, not just those whose upstream matches origin/HEAD.\n\nSo I like the idea, but would prefer the arguments to --prune-merged to \nbe upstream branches, not remotes. We could support globs so that\n\n\tgit branch --prune-merges 'origin/*'\n\nwould clean up all the branches whose upstream is on origin if that is \nuseful.\n\nThanks\n\nPhillip\n\n> A branch whose upstream no longer resolves locally is left alone --\n> its disappearance is not, on its own, evidence that the work was\n> integrated. With --force, skip the reachability check and delete\n> every branch in the candidate set. The currently checked-out\n> branch in any worktree is always preserved, as is the local branch\n> that mirrors <remote>'s default branch.\n> \n> Reachability is read from whatever the remote-tracking refs say\n> locally, so the natural workflow is\n> \n> \tgit fetch <remote>\n> \tgit branch --prune-merged <remote>\n> \n> with no implicit cleanup driven by fetch itself.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc |  19 +++++\n>   builtin/branch.c              | 143 +++++++++++++++++++++++++++++-----\n>   t/t3200-branch.sh             |  83 ++++++++++++++++++++\n>   3 files changed, 226 insertions(+), 19 deletions(-)\n> \n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index 5773104cd3..375a0a68da 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n>   git branch (-d|-D) [-r] <branch-name>...\n>   git branch --edit-description [<branch-name>]\n>   git branch --forked <remote>...\n> +git branch --prune-merged <remote>...\n>   \n>   DESCRIPTION\n>   -----------\n> @@ -211,6 +212,24 @@ Each _<remote>_ may be either the name of a configured remote\n>   `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n>   (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n>   \n> +`--prune-merged`::\n> +\tDelete the local branches that `--forked` would list for\n> +\tthe same _<remote>_ arguments, but only those whose tip is\n> +\treachable from their configured upstream remote-tracking\n> +\tbranch (`branch.<name>.merge`). In other words: the work on\n> +\tthe branch has already landed on the upstream it tracks, so\n> +\tthe local copy is no longer needed.\n> ++\n> +Run `git fetch` first so the upstream remote-tracking branches\n> +reflect the current state of _<remote>_; reachability is checked\n> +against whatever the remote-tracking refs say locally.\n> ++\n> +A branch whose upstream no longer resolves locally is left alone\n> +(its disappearance is not, on its own, evidence that the work was\n> +integrated). The currently checked-out branch in any worktree is\n> +always preserved, as is the local branch that mirrors _<remote>_'s\n> +default branch.\n> +\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1941f8a9ad..6fe2ffd7e8 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -21,6 +21,7 @@\n>   #include \"branch.h\"\n>   #include \"path.h\"\n>   #include \"string-list.h\"\n> +#include \"strvec.h\"\n>   #include \"column.h\"\n>   #include \"utf8.h\"\n>   #include \"ref-filter.h\"\n> @@ -171,8 +172,8 @@ static int branch_merged(int kind, const char *name,\n>   \t * any of the following code, but during the transition period,\n>   \t * a gentle reminder is in order.\n>   \t */\n> -\tif (head_rev != reference_rev) {\n> -\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n> +\tif (head_rev && head_rev != reference_rev) {\n> +\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n>   \t\tif (expect < 0)\n>   \t\t\texit(128);\n>   \t\tif (expect == merged)\n> @@ -227,7 +228,9 @@ static void delete_branch_config(const char *branchname)\n>   \tstrbuf_release(&buf);\n>   }\n>   \n> -static int delete_branches(int argc, const char **argv, int force, int kinds,\n> +static int delete_branches(int argc, const char **argv,\n> +\t\t\t   int no_head_fallback,\n> +\t\t\t   int force, int kinds,\n>   \t\t\t   int quiet, int warn_only, int *n_not_merged)\n>   {\n>   \tstruct commit *head_rev = NULL;\n> @@ -262,7 +265,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t}\n>   \tbranch_name_pos = strcspn(fmt, \"%\");\n>   \n> -\tif (!force)\n> +\tif (!force && !no_head_fallback)\n>   \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n>   \n>   \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n> @@ -317,8 +320,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t}\n>   \n>   \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n> -\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n> -\t\t\t\t\tforce, warn_only, n_not_merged)) {\n> +\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n> +\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n>   \t\t\tif (!warn_only)\n>   \t\t\t\tret = 1;\n>   \t\t\tgoto next;\n> @@ -753,36 +756,131 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n>   \treturn 0;\n>   }\n>   \n> -static int list_forked_branches(int argc, const char **argv)\n> +static void collect_default_branch_refs(const struct string_list *remote_names,\n> +\t\t\t\t\tstruct string_list *out)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct string_list_item *item;\n> +\n> +\tfor_each_string_list_item(item, remote_names) {\n> +\t\tstruct strbuf head = STRBUF_INIT;\n> +\t\tconst char *target;\n> +\n> +\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n> +\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n> +\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n> +\t\t\t\t\t\t NULL, NULL);\n> +\t\tif (target && starts_with(target, \"refs/remotes/\"))\n> +\t\t\tstring_list_insert(out, target);\n> +\t\tstrbuf_release(&head);\n> +\t}\n> +}\n> +\n> +static void collect_forked_set(int argc, const char **argv,\n> +\t\t\t       struct string_list *protected_default_refs,\n> +\t\t\t       struct string_list *out)\n>   {\n>   \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n>   \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n> -\tstruct string_list out = STRING_LIST_INIT_DUP;\n> -\tstruct string_list_item *item;\n>   \tstruct forked_cb cb = {\n>   \t\t.remote_names = &remote_names,\n>   \t\t.tracking_refs = &tracking_refs,\n> -\t\t.out = &out,\n> +\t\t.out = out,\n>   \t};\n>   \n> -\tif (!argc)\n> -\t\tdie(_(\"--forked requires at least one <remote>\"));\n> -\n>   \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n>   \n>   \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n>   \t\t\t\t collect_forked_branch, &cb);\n>   \n> -\tstring_list_sort(&out);\n> -\tfor_each_string_list_item(item, &out)\n> -\t\tputs(item->string);\n> +\tstring_list_sort(out);\n> +\n> +\tif (protected_default_refs)\n> +\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n>   \n>   \tstring_list_clear(&remote_names, 0);\n>   \tstring_list_clear(&tracking_refs, 0);\n> +}\n> +\n> +static int list_forked_branches(int argc, const char **argv)\n> +{\n> +\tstruct string_list out = STRING_LIST_INIT_DUP;\n> +\tstruct string_list_item *item;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--forked requires at least one <remote>\"));\n> +\n> +\tcollect_forked_set(argc, argv, NULL, &out);\n> +\tfor_each_string_list_item(item, &out)\n> +\t\tputs(item->string);\n> +\n>   \tstring_list_clear(&out, 0);\n>   \treturn 0;\n>   }\n>   \n> +static int prune_merged_branches(int argc, const char **argv, int quiet)\n> +{\n> +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n> +\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n> +\tstruct strvec deletable = STRVEC_INIT;\n> +\tstruct string_list_item *item;\n> +\tint n_not_merged = 0;\n> +\tint ret = 0;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n> +\n> +\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n> +\n> +\tfor_each_string_list_item(item, &candidates) {\n> +\t\tconst char *short_name = item->string;\n> +\t\tstruct strbuf full = STRBUF_INIT;\n> +\t\tstruct branch *branch;\n> +\t\tconst char *upstream;\n> +\n> +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n> +\t\tif (branch_checked_out(full.buf)) {\n> +\t\t\tstrbuf_release(&full);\n> +\t\t\tcontinue;\n> +\t\t}\n> +\t\tstrbuf_release(&full);\n> +\n> +\t\tbranch = branch_get(short_name);\n> +\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n> +\t\tif (!upstream ||\n> +\t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n> +\t\t\t\t     upstream))\n> +\t\t\tcontinue;\n> +\t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n> +\t\t\tconst char *leaf = strrchr(upstream, '/');\n> +\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n> +\t\t\t\tcontinue;\n> +\t\t}\n> +\n> +\t\tstrvec_push(&deletable, short_name);\n> +\t}\n> +\n> +\tif (deletable.nr)\n> +\t\tret = delete_branches(deletable.nr, deletable.v,\n> +\t\t\t\t      1, 0,\n> +\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n> +\t\t\t\t      1, &n_not_merged);\n> +\n> +\tif (n_not_merged && !quiet)\n> +\t\tfprintf(stderr,\n> +\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n> +\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   \"Skipped %d branches that are not fully merged; \"\n> +\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   n_not_merged),\n> +\t\t\tn_not_merged);\n> +\n> +\tstrvec_clear(&deletable);\n> +\tstring_list_clear(&candidates, 0);\n> +\tstring_list_clear(&protected_default_refs, 0);\n> +\treturn ret;\n> +}\n> +\n>   static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n>   \n>   static int edit_branch_description(const char *branch_name)\n> @@ -825,6 +923,7 @@ int cmd_branch(int argc,\n>   \tint delete = 0, rename = 0, copy = 0, list = 0,\n>   \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>   \tint forked = 0;\n> +\tint prune_merged = 0;\n>   \tconst char *new_upstream = NULL;\n>   \tint noncreate_actions = 0;\n>   \t/* possible options */\n> @@ -880,6 +979,8 @@ int cmd_branch(int argc,\n>   \t\t\t N_(\"edit the description for the branch\")),\n>   \t\tOPT_BOOL(0, \"forked\", &forked,\n>   \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n> +\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n> +\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n>   \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>   \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>   \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -924,7 +1025,8 @@ int cmd_branch(int argc,\n>   \t\t\t     0);\n>   \n>   \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n> -\t    !show_current && !unset_upstream && !forked && argc == 0)\n> +\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n> +\t    argc == 0)\n>   \t\tlist = 1;\n>   \n>   \tif (filter.with_commit || filter.no_commit ||\n> @@ -933,7 +1035,7 @@ int cmd_branch(int argc,\n>   \n>   \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>   \t\t\t    !!show_current + !!list + !!edit_description +\n> -\t\t\t    !!unset_upstream + !!forked;\n> +\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n>   \tif (noncreate_actions > 1)\n>   \t\tusage_with_options(builtin_branch_usage, options);\n>   \n> @@ -971,12 +1073,15 @@ int cmd_branch(int argc,\n>   \tif (delete) {\n>   \t\tif (!argc)\n>   \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n> +\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n>   \t\t\t\t      quiet, 0, NULL);\n>   \t\tgoto out;\n>   \t} else if (forked) {\n>   \t\tret = list_forked_branches(argc, argv);\n>   \t\tgoto out;\n> +\t} else if (prune_merged) {\n> +\t\tret = prune_merged_branches(argc, argv, quiet);\n> +\t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n>   \t\tret = 0;\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 24a3ec44ee..94ea493aee 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1771,4 +1771,87 @@ test_expect_success '--forked requires at least one <remote>' '\n>   \ttest_grep \"at least one <remote>\" err\n>   '\n>   \n> +test_expect_success '--prune-merged: setup' '\n> +\ttest_create_repo pm-upstream &&\n> +\ttest_commit -C pm-upstream base &&\n> +\tgit -C pm-upstream checkout -b next &&\n> +\ttest_commit -C pm-upstream one-commit &&\n> +\ttest_commit -C pm-upstream two-commit &&\n> +\tgit -C pm-upstream branch one HEAD~ &&\n> +\tgit -C pm-upstream branch two HEAD &&\n> +\tgit -C pm-upstream branch wip main &&\n> +\tgit -C pm-upstream checkout main\n> +'\n> +\n> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> +\ttest_when_finished \"rm -rf pm-merged\" &&\n> +\tgit clone pm-upstream pm-merged &&\n> +\tgit -C pm-merged branch one one-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-merged branch two two-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n> +\n> +\tgit -C pm-merged branch --prune-merged origin &&\n> +\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged spares branches with un-integrated commits' '\n> +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n> +\tgit clone pm-upstream pm-unmerged &&\n> +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n> +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-unmerged local-only &&\n> +\tgit -C pm-unmerged checkout - &&\n> +\n> +\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n> +\ttest_grep \"not fully merged\" err &&\n> +\ttest_grep \"Skipped 1 branch\" err &&\n> +\ttest_grep \"git branch -D\" err &&\n> +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n> +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n> +'\n> +\n> +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n> +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n> +\tgit clone pm-upstream pm-upstream-gone &&\n> +\tgit -C pm-upstream-gone branch one one-commit &&\n> +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n> +\tgit -C pm-upstream-gone branch --prune-merged origin &&\n> +\n> +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged never deletes the checked-out branch' '\n> +\ttest_when_finished \"rm -rf pm-head\" &&\n> +\tgit clone pm-upstream pm-head &&\n> +\tgit -C pm-head checkout -b one one-commit &&\n> +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-head branch --prune-merged origin &&\n> +\n> +\tgit -C pm-head rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged spares the local default branch' '\n> +\ttest_when_finished \"rm -rf pm-default\" &&\n> +\tgit clone pm-upstream pm-default &&\n> +\tgit -C pm-default checkout --detach &&\n> +\tgit -C pm-default branch --prune-merged origin &&\n> +\tgit -C pm-default rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n> +\ttest_when_finished \"rm -rf pm-default-alias\" &&\n> +\tgit clone pm-upstream pm-default-alias &&\n> +\tgit -C pm-default-alias branch --track trunk origin/main &&\n> +\tgit -C pm-default-alias checkout --detach &&\n> +\tgit -C pm-default-alias branch --prune-merged origin &&\n> +\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n> +\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n> +'\n> +\n>   test_done\n\n"},{"id":"543536","messageId":"4b7d3370-6d7a-4cbf-a02e-53db8ca58b6e@gmail.com","threadId":"65574","inReplyTo":"6ae95d3f98212ca449cb81d3cfe332e78b8011ea.1778700883.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v9 5/5] branch: add --all-remotes flag","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-18T15:27:10Z","receivedAt":"2026-05-18T15:27:14Z","isPatch":true,"body":"Hi Harald\n\nOn 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> +`--all-remotes`::\n> +\tWith `--forked` or `--prune-merged`, act on every\n> +\tconfigured remote in addition to any explicit _<remote>_\n> +\targuments.\n\nWhy do we except additional arguments if this option includes all the \nremotes?\n\nThanks\n\nPhillip\n\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index bc4f4a4a18..7d45bada45 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -687,6 +687,13 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n>   \tfree_worktrees(worktrees);\n>   }\n>   \n> +static int collect_remote_name(struct remote *remote, void *cb_data)\n> +{\n> +\tstruct string_list *remote_names = cb_data;\n> +\tstring_list_insert(remote_names, remote->name);\n> +\treturn 0;\n> +}\n> +\n>   static void parse_forked_args(int argc, const char **argv,\n>   \t\t\t      struct string_list *remote_names,\n>   \t\t\t      struct string_list *tracking_refs)\n> @@ -776,7 +783,7 @@ static void collect_default_branch_refs(const struct string_list *remote_names,\n>   \t}\n>   }\n>   \n> -static void collect_forked_set(int argc, const char **argv,\n> +static void collect_forked_set(int argc, const char **argv, int all_remotes,\n>   \t\t\t       struct string_list *protected_default_refs,\n>   \t\t\t       struct string_list *out)\n>   {\n> @@ -789,6 +796,8 @@ static void collect_forked_set(int argc, const char **argv,\n>   \t};\n>   \n>   \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n> +\tif (all_remotes)\n> +\t\tfor_each_remote(collect_remote_name, &remote_names);\n>   \n>   \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n>   \t\t\t\t collect_forked_branch, &cb);\n> @@ -802,15 +811,15 @@ static void collect_forked_set(int argc, const char **argv,\n>   \tstring_list_clear(&tracking_refs, 0);\n>   }\n>   \n> -static int list_forked_branches(int argc, const char **argv)\n> +static int list_forked_branches(int argc, const char **argv, int all_remotes)\n>   {\n>   \tstruct string_list out = STRING_LIST_INIT_DUP;\n>   \tstruct string_list_item *item;\n>   \n> -\tif (!argc)\n> -\t\tdie(_(\"--forked requires at least one <remote>\"));\n> +\tif (!argc && !all_remotes)\n> +\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n>   \n> -\tcollect_forked_set(argc, argv, NULL, &out);\n> +\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n>   \tfor_each_string_list_item(item, &out)\n>   \t\tputs(item->string);\n>   \n> @@ -818,7 +827,8 @@ static int list_forked_branches(int argc, const char **argv)\n>   \treturn 0;\n>   }\n>   \n> -static int prune_merged_branches(int argc, const char **argv, int quiet)\n> +static int prune_merged_branches(int argc, const char **argv,\n> +\t\t\t\t int all_remotes, int quiet)\n>   {\n>   \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n>   \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n> @@ -827,10 +837,11 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n>   \tint n_not_merged = 0;\n>   \tint ret = 0;\n>   \n> -\tif (!argc)\n> -\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n> +\tif (!argc && !all_remotes)\n> +\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n>   \n> -\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n> +\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n> +\t\t\t   &candidates);\n>   \n>   \tfor_each_string_list_item(item, &candidates) {\n>   \t\tconst char *short_name = item->string;\n> @@ -943,6 +954,7 @@ int cmd_branch(int argc,\n>   \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>   \tint forked = 0;\n>   \tint prune_merged = 0;\n> +\tint all_remotes = 0;\n>   \tconst char *new_upstream = NULL;\n>   \tint noncreate_actions = 0;\n>   \t/* possible options */\n> @@ -1000,6 +1012,9 @@ int cmd_branch(int argc,\n>   \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n>   \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n>   \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n> +\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n> +\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n> +\t\t\tPARSE_OPT_NONEG),\n>   \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>   \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>   \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -1043,6 +1058,10 @@ int cmd_branch(int argc,\n>   \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n>   \t\t\t     0);\n>   \n> +\tif (all_remotes && !forked && !prune_merged)\n> +\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n> +\n> +\n>   \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n>   \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n>   \t    argc == 0)\n> @@ -1096,10 +1115,10 @@ int cmd_branch(int argc,\n>   \t\t\t\t      quiet, 0, NULL);\n>   \t\tgoto out;\n>   \t} else if (forked) {\n> -\t\tret = list_forked_branches(argc, argv);\n> +\t\tret = list_forked_branches(argc, argv, all_remotes);\n>   \t\tgoto out;\n>   \t} else if (prune_merged) {\n> -\t\tret = prune_merged_branches(argc, argv, quiet);\n> +\t\tret = prune_merged_branches(argc, argv, all_remotes, quiet);\n>   \t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 885a275e36..a36e5ee80a 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1771,6 +1771,27 @@ test_expect_success '--forked requires at least one <remote>' '\n>   \ttest_grep \"at least one <remote>\" err\n>   '\n>   \n> +test_expect_success '--forked --all-remotes covers every configured remote' '\n> +\tgit -C forked branch --forked --all-remotes >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-one\n> +\tlocal-two\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n> +\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n> +\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n> +'\n> +\n> +test_expect_success '--all-remotes alone is rejected' '\n> +\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n> +\ttest_grep \"requires --forked or --prune-merged\" err\n> +'\n> +\n>   test_expect_success '--prune-merged: setup' '\n>   \ttest_create_repo pm-upstream &&\n>   \ttest_commit -C pm-upstream base &&\n> @@ -1881,4 +1902,27 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n>   \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>   '\n>   \n> +test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n> +\ttest_when_finished \"rm -rf pm-allremotes pm-other\" &&\n> +\tgit clone pm-upstream pm-allremotes &&\n> +\ttest_create_repo pm-other &&\n> +\ttest_commit -C pm-other other-base &&\n> +\tgit -C pm-other checkout -b stable &&\n> +\ttest_commit -C pm-other foreign-commit &&\n> +\tgit -C pm-other branch foreign HEAD &&\n> +\tgit -C pm-other checkout main &&\n> +\n> +\tgit -C pm-allremotes remote add other ../pm-other &&\n> +\tgit -C pm-allremotes fetch other &&\n> +\tgit -C pm-allremotes branch one one-commit &&\n> +\tgit -C pm-allremotes branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-allremotes branch foreign other/foreign &&\n> +\tgit -C pm-allremotes branch --set-upstream-to=other/stable foreign &&\n> +\n> +\tgit -C pm-allremotes branch --prune-merged --all-remotes &&\n> +\n> +\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n> +'\n> +\n>   test_done\n\n"},{"id":"543811","messageId":"f1d15d08-6fee-479f-8ed0-34efd256d8dc@gmail.com","threadId":"65574","inReplyTo":"6501a3d5-a5ec-421b-8526-ee7d4ae5ea98@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-21T09:46:17Z","receivedAt":"2026-05-21T09:46:23Z","isPatch":true,"body":"Hi Harald\n\nA couple more thoughts ...\n\nOn 18/05/2026 16:27, Phillip Wood wrote:\n> On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:\n>> From: Harald Nordgren <haraldnordgren@gmail.com>\n>>\n>> Delete the local branches that --forked <remote> would list, but\n>> only those whose tip is reachable from their configured upstream\n>> remote-tracking branch (branch.<name>.merge): the work has already\n>> landed on the upstream it tracks, so the local copy is no longer\n>> needed.\n\nWhile we want to clean up topic branches, we want to avoid cleaning up \nbranches like \"master\" which follow an upstream branch and therefore \nlook like they've been merged straight after they've been pulled. So I \nthink as well as checking that the local branch is merged into its \nupstream branch, we want to check that the local branch is not pushed to \nthe upstream branch i.e. that branch@{upstream} != branch@{push}. That \nshould also avoid deleting newly created topic branches that match their \nupstream (I think that's probably less likely to happen in practice as \nI'd expect the branch to be checked out and therefore protected against \ndeletion).\n\nAlso as this is a destructive operation (there is no way to restore a \ndeleted branch and its reflog) it would be good to have a --dry-run option.\n\nThanks\n\nPhillip\n\n"},{"id":"543824","messageId":"CAHwyqnVMpBA=fk8giP4E45FR_7UDpgr8GHYXV9Q2C3Skxxb5gA@mail.gmail.com","threadId":"65574","inReplyTo":"6501a3d5-a5ec-421b-8526-ee7d4ae5ea98@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-21T12:37:04Z","receivedAt":"2026-05-21T12:37:43Z","isPatch":true,"body":"> I think being able to prune branches that have been merged into their\n> upstream is a good idea. However, I find the focus on remotes rather\n> than upstream branches in the UI a bit confusing. While the upstream of\n> a branch is often a remote tracking branch it doesn't have to be. For my\n> personal projects I often do\n>\n>         git checkout -b topic master\n>\n> and it would be nice to be able to run\n>\n>         git branch --prune-merged master\n>\n> to clean up those topics that have been merged. Similarly I think it is\n> confusing that\n>\n>         git checkout -b topic origin\n>\n> starts a branch from the default branch on origin, but if I run\n>\n>         git branch --prune-merged origin\n>\n> to clean it up, it will clean up all the branches with an upstream on\n> origin, not just those whose upstream matches origin/HEAD.\n>\n> So I like the idea, but would prefer the arguments to --prune-merged to\n> be upstream branches, not remotes. We could support globs so that\n>\n>         git branch --prune-merges 'origin/*'\n>\n> would clean up all the branches whose upstream is on origin if that is\n> useful.\n>\n> Thanks\n>\n> Phillip\n\nHi Phillip!\n\nThis seems like a big change. It almost becomes a different feature.\nWould be interesting to hear what others have to say as well.\n\n\nHarald\n"},{"id":"543826","messageId":"xmqqmrxs28ne.fsf@gitster.g","threadId":"65574","inReplyTo":"6501a3d5-a5ec-421b-8526-ee7d4ae5ea98@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-21T13:29:25Z","receivedAt":"2026-05-21T13:29:27Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> I think being able to prune branches that have been merged into their \n> upstream is a good idea. However, I find the focus on remotes rather \n> than upstream branches in the UI a bit confusing. While the upstream of \n> a branch is often a remote tracking branch it doesn't have to be. For my \n> personal projects I often do\n>\n> \tgit checkout -b topic master\n>\n> and it would be nice to be able to run\n>\n> \tgit branch --prune-merged master\n>\n> to clean up those topics that have been merged.\n\nExcellent suggestion.\n\nThe task at hand is to make a list of things that \"track\" (in the\n\"checkout -t\" sense) something, see which one in that list are\ndescendant of that something, and removing those that have already\nbeen merged into that something.  There is nothing that requires\nthat something to be a remote-tracking branch at all.\n\nIf\n\n    git branch --forked master\n\n(i.e., \"who tracks 'master' in the 'checkout -t' sense\") were\navailable, then\n\n    git branch --merged master | sort >1\n    git branch --forked master | sort >2\n    comm -12 1 2\n\nwould give us the list of branches that forked from 'master' and\nhave already been merged, i.e., candidate to be removed via the\n\"prune-merged\" mechanism.  With the built-in \"git branch -d\"\nprotection to require capital \"-D\" to force removal of a new work\nthat is not merged to tracked, we actually need only \n\n    git branch --forked master |\n    xargs git branch -d\n\nto do so.  We may want to give --dry-run to \"git branch -d\" so that\nyou can say\n\n    git branch --forked master |\n    xargs git branch -d --dry-run\n\nto see what would be removed.\n\nAnd again, there is no strong reason why \"--forked\" has to work only\nwith remote-tracking branches.\n\n> Similarly I think it is \n> confusing that\n>\n> \tgit checkout -b topic origin\n>\n> starts a branch from the default branch on origin, but if I run\n>\n> \tgit branch --prune-merged origin\n\nSorry, this was my bad.  I agree that it is not a useful thing for\n\"origin\" to mean \"origin/*\" here when it is established that\n\"origin\" to mean \"origin/HEAD\".\n"},{"id":"543852","messageId":"CAHwyqnVhhwT80Ao+7QLUAsTnUJaN5vE=ZiaxeqF3rYxxiD_Qww@mail.gmail.com","threadId":"65574","inReplyTo":"f1d15d08-6fee-479f-8ed0-34efd256d8dc@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-21T19:16:44Z","receivedAt":"2026-05-21T19:17:23Z","isPatch":true,"body":"> While we want to clean up topic branches, we want to avoid cleaning up\n> branches like \"master\" which follow an upstream branch and therefore\n> look like they've been merged straight after they've been pulled. So I\n> think as well as checking that the local branch is merged into its\n> upstream branch, we want to check that the local branch is not pushed to\n> the upstream branch i.e. that branch@{upstream} != branch@{push}.\n\nThis one I handle already by letting the default branch be guarded.\n\n\nHarald\n\nOn Thu, May 21, 2026 at 11:46 AM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> Hi Harald\n>\n> A couple more thoughts ...\n>\n> On 18/05/2026 16:27, Phillip Wood wrote:\n> > On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:\n> >> From: Harald Nordgren <haraldnordgren@gmail.com>\n> >>\n> >> Delete the local branches that --forked <remote> would list, but\n> >> only those whose tip is reachable from their configured upstream\n> >> remote-tracking branch (branch.<name>.merge): the work has already\n> >> landed on the upstream it tracks, so the local copy is no longer\n> >> needed.\n>\n> While we want to clean up topic branches, we want to avoid cleaning up\n> branches like \"master\" which follow an upstream branch and therefore\n> look like they've been merged straight after they've been pulled. So I\n> think as well as checking that the local branch is merged into its\n> upstream branch, we want to check that the local branch is not pushed to\n> the upstream branch i.e. that branch@{upstream} != branch@{push}. That\n> should also avoid deleting newly created topic branches that match their\n> upstream (I think that's probably less likely to happen in practice as\n> I'd expect the branch to be checked out and therefore protected against\n> deletion).\n>\n> Also as this is a destructive operation (there is no way to restore a\n> deleted branch and its reflog) it would be good to have a --dry-run option.\n>\n> Thanks\n>\n> Phillip\n>\n"},{"id":"543855","messageId":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v9.git.git.1778700883.gitgitgadget@gmail.com","subject":"[PATCH v10 0/4] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-21T22:40:00Z","receivedAt":"2026-05-21T22:40:08Z","isPatch":true,"body":" * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-* all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nHarald Nordgren (4):\n  branch: add --forked <branch>\n  branch: add --prune-merged <branch>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --dry-run for --prune-merged\n\n Documentation/config/branch.adoc |   5 +\n Documentation/git-branch.adoc    |  33 ++++\n builtin/branch.c                 | 253 +++++++++++++++++++++++++--\n t/t3200-branch.sh                | 282 +++++++++++++++++++++++++++++++\n 4 files changed, 560 insertions(+), 13 deletions(-)\n\n\nbase-commit: aec3f587505a472db67e9462d0702e7d463a449d\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v10\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v10\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v9:\n\n 1:  9324b26091 ! 1:  f2df159830 branch: add --forked <remote>\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --forked <remote>\n     +    branch: add --forked <branch>\n      \n     -    List local branches whose configured upstream falls within any of\n     -    the given <remote> arguments. <remote> may be either a configured\n     -    remote name (matching all of its remote-tracking branches) or a\n     -    single remote-tracking branch. Multiple <remote> arguments are\n     -    unioned.\n     +            git branch --forked <branch>...\n      \n     -    This is the building block for --prune-merged, which deletes the\n     -    listed branches.\n     +    lists local branches whose configured upstream matches any\n     +    of the given <branch> arguments.\n     +\n     +    Each <branch> is resolved to the same kind of ref that\n     +    branch.<name>.remote and branch.<name>.merge together point at:\n     +    a remote-tracking branch (e.g. origin/master), or, for branches\n     +    tracking a local upstream, a local branch (e.g. master).\n     +    Shell-style globs are also accepted (e.g. 'origin/*'). Multiple\n     +    arguments are unioned.\n     +\n     +    This is the building block for --prune-merged.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     -+git branch --forked <remote>...\n     ++git branch --forked <branch>...\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n       \tnothing is printed.\n       \n      +`--forked`::\n     -+\tList local branches that fork from any of the given _<remote>_\n     -+\targuments, that is, those whose configured upstream\n     -+\t(`branch.<name>.merge`) is one of those remotes' remote-tracking\n     -+\tbranches.\n     -++\n     -+Each _<remote>_ may be either the name of a configured remote\n     -+(e.g. `origin`, meaning any branch tracking a\n     -+`refs/remotes/origin/*` ref) or a specific remote-tracking branch\n     -+(e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n     ++\tList local branches whose configured upstream matches any\n     ++\tof the given _<branch>_ arguments. Each argument is either\n     ++\ta ref (e.g. `origin/master`, `master`) or a shell-style\n     ++\tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n      +\n       `-v`::\n       `-vv`::\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     +@@\n     + #include \"help.h\"\n     + #include \"advice.h\"\n     + #include \"commit-reach.h\"\n     ++#include \"wildmatch.h\"\n     + \n     + static const char * const builtin_branch_usage[] = {\n     + \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n      @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] --forked <remote>...\"),\n     ++\tN_(\"git branch [<options>] --forked <branch>...\"),\n       \tNULL\n       };\n       \n     +@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     + \n     + static int check_branch_commit(const char *branchname, const char *refname,\n     + \t\t\t       const struct object_id *oid, struct commit *head_rev,\n     +-\t\t\t       int kinds, int force)\n     ++\t\t\t       int kinds, int force, int warn_only,\n     ++\t\t\t       int *n_not_merged)\n     + {\n     + \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     + \tif (!force && !rev) {\n     +@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n     + \t\treturn -1;\n     + \t}\n     + \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     +-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n     +-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     +-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     +-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     ++\t\tif (warn_only) {\n     ++\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n     ++\t\t\t\tbranchname);\n     ++\t\t} else {\n     ++\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n     ++\t\t\t      branchname);\n     ++\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     ++\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     ++\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     ++\t\t}\n     ++\t\tif (n_not_merged)\n     ++\t\t\t(*n_not_merged)++;\n     + \t\treturn -1;\n     + \t}\n     + \treturn 0;\n     +@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     + }\n     + \n     + static int delete_branches(int argc, const char **argv, int force, int kinds,\n     +-\t\t\t   int quiet)\n     ++\t\t\t   int quiet, int warn_only, int *n_not_merged)\n     + {\n     + \tstruct commit *head_rev = NULL;\n     + \tstruct object_id oid;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \n     + \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     + \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n     +-\t\t\t\t\tforce)) {\n     +-\t\t\tret = 1;\n     ++\t\t\t\t\tforce, warn_only, n_not_merged)) {\n     ++\t\t\tif (!warn_only)\n     ++\t\t\t\tret = 1;\n     + \t\t\tgoto next;\n     + \t\t}\n     + \n      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n       \tfree_worktrees(worktrees);\n       }\n       \n      +static void parse_forked_args(int argc, const char **argv,\n     -+\t\t\t      struct string_list *remote_names,\n     -+\t\t\t      struct string_list *tracking_refs)\n     ++\t\t\t      struct string_list *upstream_patterns)\n      +{\n      +\tint i;\n      +\n      +\tfor (i = 0; i < argc; i++) {\n      +\t\tconst char *arg = argv[i];\n     -+\t\tstruct remote *remote;\n      +\t\tstruct object_id oid;\n      +\t\tchar *full_ref = NULL;\n     ++\t\tconst char *short_ref;\n      +\n     -+\t\tremote = remote_get(arg);\n     -+\t\tif (remote && remote_is_configured(remote, 0)) {\n     -+\t\t\tstring_list_insert(remote_names, remote->name);\n     ++\t\tif (has_glob_specials(arg)) {\n     ++\t\t\tstring_list_insert(upstream_patterns, arg);\n      +\t\t\tcontinue;\n      +\t\t}\n      +\n      +\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n      +\t\t\t\t  &full_ref, 0) == 1 &&\n     -+\t\t    starts_with(full_ref, \"refs/remotes/\")) {\n     -+\t\t\tstring_list_insert(tracking_refs, full_ref);\n     ++\t\t    (skip_prefix(full_ref, \"refs/heads/\", &short_ref) ||\n     ++\t\t     skip_prefix(full_ref, \"refs/remotes/\", &short_ref))) {\n     ++\t\t\tstring_list_insert(upstream_patterns, short_ref);\n      +\t\t\tfree(full_ref);\n      +\t\t\tcontinue;\n      +\t\t}\n      +\t\tfree(full_ref);\n      +\n     -+\t\tdie(_(\"'%s' is neither a configured remote nor a \"\n     -+\t\t      \"remote-tracking branch\"), arg);\n     ++\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n      +\t}\n      +}\n      +\n     -+static int branch_is_forked(const char *short_name,\n     -+\t\t\t    const struct string_list *remote_names,\n     -+\t\t\t    const struct string_list *tracking_refs)\n     -+{\n     -+\tstruct branch *branch = branch_get(short_name);\n     -+\tconst char *upstream;\n     -+\n     -+\tif (!branch || !branch->remote_name)\n     -+\t\treturn 0;\n     -+\n     -+\tif (string_list_has_string(remote_names, branch->remote_name))\n     -+\t\treturn 1;\n     -+\n     -+\tupstream = branch_get_upstream(branch, NULL);\n     -+\tif (upstream && string_list_has_string(tracking_refs, upstream))\n     -+\t\treturn 1;\n     -+\n     -+\treturn 0;\n     -+}\n     -+\n      +struct forked_cb {\n     -+\tconst struct string_list *remote_names;\n     -+\tconst struct string_list *tracking_refs;\n     ++\tconst struct string_list *upstream_patterns;\n      +\tstruct string_list *out;\n      +};\n      +\n      +static int collect_forked_branch(const struct reference *ref, void *cb_data)\n      +{\n      +\tstruct forked_cb *cb = cb_data;\n     ++\tstruct branch *branch;\n     ++\tconst char *upstream, *short_upstream;\n     ++\tconst struct string_list_item *item;\n      +\n      +\tif (ref->flags & REF_ISSYMREF)\n      +\t\treturn 0;\n     -+\tif (branch_is_forked(ref->name, cb->remote_names, cb->tracking_refs))\n     -+\t\tstring_list_append(cb->out, ref->name);\n     ++\tbranch = branch_get(ref->name);\n     ++\tif (!branch)\n     ++\t\treturn 0;\n     ++\tupstream = branch_get_upstream(branch, NULL);\n     ++\tif (!upstream)\n     ++\t\treturn 0;\n     ++\tshort_upstream = upstream;\n     ++\t(void)(skip_prefix(short_upstream, \"refs/heads/\", &short_upstream) ||\n     ++\t       skip_prefix(short_upstream, \"refs/remotes/\", &short_upstream));\n     ++\n     ++\tfor_each_string_list_item(item, cb->upstream_patterns)\n     ++\t\tif (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {\n     ++\t\t\tstring_list_append(cb->out, ref->name)->util =\n     ++\t\t\t\txstrdup(upstream);\n     ++\t\t\treturn 0;\n     ++\t\t}\n      +\treturn 0;\n      +}\n      +\n     -+static int list_forked_branches(int argc, const char **argv)\n     ++static void collect_forked_set(int argc, const char **argv,\n     ++\t\t\t       struct string_list *out)\n      +{\n     -+\tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n     -+\tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list out = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list_item *item;\n     ++\tstruct string_list upstream_patterns = STRING_LIST_INIT_DUP;\n      +\tstruct forked_cb cb = {\n     -+\t\t.remote_names = &remote_names,\n     -+\t\t.tracking_refs = &tracking_refs,\n     -+\t\t.out = &out,\n     ++\t\t.upstream_patterns = &upstream_patterns,\n     ++\t\t.out = out,\n      +\t};\n      +\n     -+\tif (!argc)\n     -+\t\tdie(_(\"--forked requires at least one <remote>\"));\n     -+\n     -+\tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n     ++\tparse_forked_args(argc, argv, &upstream_patterns);\n      +\n      +\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n      +\t\t\t\t collect_forked_branch, &cb);\n      +\n     -+\tstring_list_sort(&out);\n     ++\tstring_list_clear(&upstream_patterns, 0);\n     ++}\n     ++\n     ++static int list_forked_branches(int argc, const char **argv)\n     ++{\n     ++\tstruct string_list out = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list_item *item;\n     ++\n     ++\tif (!argc)\n     ++\t\tdie(_(\"--forked requires at least one <branch>\"));\n     ++\n     ++\tcollect_forked_set(argc, argv, &out);\n      +\tfor_each_string_list_item(item, &out)\n      +\t\tputs(item->string);\n      +\n     -+\tstring_list_clear(&remote_names, 0);\n     -+\tstring_list_clear(&tracking_refs, 0);\n     -+\tstring_list_clear(&out, 0);\n     ++\tstring_list_clear(&out, 1);\n      +\treturn 0;\n      +}\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n      +\t\tOPT_BOOL(0, \"forked\", &forked,\n     -+\t\t\tN_(\"list local branches forked from the given <remote>s\")),\n     ++\t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     + \tif (delete) {\n     + \t\tif (!argc)\n       \t\t\tdie(_(\"branch name required\"));\n     - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     - \t\tgoto out;\n     +-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     ++\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     ++\t\t\t\t      quiet, 0, NULL);\n     ++\t\tgoto out;\n      +\t} else if (forked) {\n      +\t\tret = list_forked_branches(argc, argv);\n     -+\t\tgoto out;\n     + \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     - \t\tret = 0;\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tgit -C forked branch --track local-one origin/one &&\n      +\tgit -C forked branch --track local-two origin/two &&\n      +\tgit -C forked branch --track local-foreign other/foreign &&\n     -+\tgit -C forked branch detached\n     ++\tgit -C forked branch detached &&\n     ++\tgit -C forked branch --track topic-on-main main\n     ++'\n     ++\n     ++test_expect_success '--forked <remote-tracking-branch> lists matching branches' '\n     ++\tgit -C forked branch --forked origin/one >actual &&\n     ++\techo local-one >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n     ++test_expect_success '--forked <local-branch> lists branches tracking that local branch' '\n     ++\tgit -C forked branch --forked main >actual &&\n     ++\techo topic-on-main >expect &&\n     ++\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <remote-name> lists branches tracking that remote' '\n     -+\tgit -C forked branch --forked origin >actual &&\n     ++test_expect_success '--forked <glob> matches every upstream under the pattern' '\n     ++\tgit -C forked branch --forked \"origin/*\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n      +\tlocal-two\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <remote-tracking-branch> lists only matching branches' '\n     -+\tgit -C forked branch --forked origin/one >actual &&\n     -+\techo local-one >expect &&\n     ++test_expect_success '--forked unions multiple <branch> arguments' '\n     ++\tgit -C forked branch --forked origin/one other/foreign >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tlocal-foreign\n     ++\tlocal-one\n     ++\tEOF\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked unions multiple <remote> arguments' '\n     -+\tgit -C forked branch --forked origin/one other >actual &&\n     ++test_expect_success '--forked combines literal and glob arguments' '\n     ++\tgit -C forked branch --forked main \"other/*\" >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tlocal-foreign\n     ++\ttopic-on-main\n     ++\tEOF\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n     ++test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n     ++\tgit -C forked branch --forked \"*/*\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-one\n     ++\tlocal-two\n     ++\tmain\n      +\tEOF\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked rejects unknown remote/ref' '\n     ++test_expect_success '--forked rejects unknown branch/pattern' '\n      +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n     -+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n     ++\ttest_grep \"not a valid branch or pattern\" err\n      +'\n      +\n     -+test_expect_success '--forked requires at least one <remote>' '\n     ++test_expect_success '--forked requires at least one <branch>' '\n      +\ttest_must_fail git -C forked branch --forked 2>err &&\n     -+\ttest_grep \"at least one <remote>\" err\n     ++\ttest_grep \"at least one <branch>\" err\n      +'\n      +\n       test_done\n 2:  2a13e5d4bc < -:  ---------- branch: let delete_branches warn instead of error on bulk refusal\n 3:  f87e96e99d ! 2:  718e28c7e0 branch: add --prune-merged <remote>\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --prune-merged <remote>\n     +    branch: add --prune-merged <branch>\n      \n     -    Delete the local branches that --forked <remote> would list, but\n     -    only those whose tip is reachable from their configured upstream\n     -    remote-tracking branch (branch.<name>.merge): the work has already\n     -    landed on the upstream it tracks, so the local copy is no longer\n     -    needed.\n     +            git branch --prune-merged <branch>...\n      \n     -    A branch whose upstream no longer resolves locally is left alone --\n     -    its disappearance is not, on its own, evidence that the work was\n     -    integrated. With --force, skip the reachability check and delete\n     -    every branch in the candidate set. The currently checked-out\n     -    branch in any worktree is always preserved, as is the local branch\n     -    that mirrors <remote>'s default branch.\n     +    deletes the local branches that --forked <branch> would list,\n     +    but only those whose tip is reachable from their configured\n     +    upstream: the work has already landed on the upstream the\n     +    branch tracks, so the local copy is no longer needed.\n      \n     -    Reachability is read from whatever the remote-tracking refs say\n     -    locally, so the natural workflow is\n     +    The following branches are always preserved:\n     +\n     +    * the currently checked-out branch in any worktree;\n     +    * any local branch whose name matches the default branch of\n     +      any configured remote (the target of\n     +      refs/remotes/<remote>/HEAD) -- typically 'main' or\n     +      'master';\n     +    * any branch whose upstream no longer resolves locally.\n     +\n     +    Reachability is read from whatever branch.<name>.merge\n     +    resolves to locally, which is usually a remote-tracking ref\n     +    but may also be a local branch. When the upstream is a\n     +    remote-tracking ref, the natural workflow is\n      \n                  git fetch <remote>\n     -            git branch --prune-merged <remote>\n     +            git branch --prune-merged <upstream-pattern>\n      \n     -    with no implicit cleanup driven by fetch itself.\n     +    so the upstream reflects the current state before pruning.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc\n      @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     - git branch --forked <remote>...\n     -+git branch --prune-merged <remote>...\n     + git branch --forked <branch>...\n     ++git branch --prune-merged <branch>...\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a configured remote\n     - `refs/remotes/origin/*` ref) or a specific remote-tracking branch\n     - (e.g. `origin/master`). Multiple _<remote>_ arguments are unioned.\n     +@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n     + \ta ref (e.g. `origin/master`, `master`) or a shell-style\n     + \tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n       \n      +`--prune-merged`::\n      +\tDelete the local branches that `--forked` would list for\n     -+\tthe same _<remote>_ arguments, but only those whose tip is\n     -+\treachable from their configured upstream remote-tracking\n     -+\tbranch (`branch.<name>.merge`). In other words: the work on\n     -+\tthe branch has already landed on the upstream it tracks, so\n     -+\tthe local copy is no longer needed.\n     ++\tthe same _<branch>_ arguments, but only those whose tip is\n     ++\treachable from their configured upstream.\n     +++\n     ++For arguments that refer to remote-tracking branches, run\n     ++`git fetch` first so reachability is checked against the\n     ++current upstream state; refs are read locally.\n      ++\n     -+Run `git fetch` first so the upstream remote-tracking branches\n     -+reflect the current state of _<remote>_; reachability is checked\n     -+against whatever the remote-tracking refs say locally.\n     ++The following branches are always preserved:\n      ++\n     -+A branch whose upstream no longer resolves locally is left alone\n     -+(its disappearance is not, on its own, evidence that the work was\n     -+integrated). The currently checked-out branch in any worktree is\n     -+always preserved, as is the local branch that mirrors _<remote>_'s\n     -+default branch.\n     ++--\n     ++* the currently checked-out branch in any worktree;\n     ++* any local branch whose name matches the default branch of\n     ++  any configured remote (the target of\n     ++  `refs/remotes/<remote>/HEAD`) -- typically `main` or\n     ++  `master`;\n     ++* any branch whose upstream no longer resolves locally.\n     ++--\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c\n       #include \"column.h\"\n       #include \"utf8.h\"\n       #include \"ref-filter.h\"\n     +@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     + \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     + \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     + \tN_(\"git branch [<options>] --forked <branch>...\"),\n     ++\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n     + \tNULL\n     + };\n     + \n      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \t * any of the following code, but during the transition period,\n       \t * a gentle reminder is in order.\n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \t\tif (expect < 0)\n       \t\t\texit(128);\n       \t\tif (expect == merged)\n     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     - \tstrbuf_release(&buf);\n     - }\n     - \n     --static int delete_branches(int argc, const char **argv, int force, int kinds,\n     -+static int delete_branches(int argc, const char **argv,\n     -+\t\t\t   int no_head_fallback,\n     -+\t\t\t   int force, int kinds,\n     - \t\t\t   int quiet, int warn_only, int *n_not_merged)\n     - {\n     - \tstruct commit *head_rev = NULL;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     - \t}\n     - \tbranch_name_pos = strcspn(fmt, \"%\");\n     - \n     --\tif (!force)\n     -+\tif (!force && !no_head_fallback)\n     - \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n     - \n     - \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     - \t\t}\n     - \n     - \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     --\t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n     --\t\t\t\t\tforce, warn_only, n_not_merged)) {\n     -+\t\t    check_branch_commit(bname.buf, name, &oid, head_rev,\n     -+\t\t\t\t\tkinds, force, warn_only, n_not_merged)) {\n     - \t\t\tif (!warn_only)\n     - \t\t\t\tret = 1;\n     - \t\t\tgoto next;\n      @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n       \treturn 0;\n       }\n       \n     --static int list_forked_branches(int argc, const char **argv)\n     -+static void collect_default_branch_refs(const struct string_list *remote_names,\n     -+\t\t\t\t\tstruct string_list *out)\n     ++static int collect_default_branch_name(struct remote *remote, void *cb_data)\n      +{\n     ++\tstruct string_list *protected = cb_data;\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     -+\tstruct string_list_item *item;\n     -+\n     -+\tfor_each_string_list_item(item, remote_names) {\n     -+\t\tstruct strbuf head = STRBUF_INIT;\n     -+\t\tconst char *target;\n     -+\n     -+\t\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", item->string);\n     -+\t\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n     -+\t\t\t\t\t\t RESOLVE_REF_NO_RECURSE,\n     -+\t\t\t\t\t\t NULL, NULL);\n     -+\t\tif (target && starts_with(target, \"refs/remotes/\"))\n     -+\t\t\tstring_list_insert(out, target);\n     -+\t\tstrbuf_release(&head);\n     ++\tstruct strbuf head = STRBUF_INIT;\n     ++\tconst char *target;\n     ++\n     ++\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n     ++\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n     ++\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n     ++\tif (target) {\n     ++\t\tconst char *leaf = strrchr(target, '/');\n     ++\t\tif (leaf)\n     ++\t\t\tstring_list_insert(protected, leaf + 1);\n      +\t}\n     ++\tstrbuf_release(&head);\n     ++\treturn 0;\n      +}\n      +\n     -+static void collect_forked_set(int argc, const char **argv,\n     -+\t\t\t       struct string_list *protected_default_refs,\n     -+\t\t\t       struct string_list *out)\n     + static void collect_forked_set(int argc, const char **argv,\n     + \t\t\t       struct string_list *out)\n       {\n     - \tstruct string_list remote_names = STRING_LIST_INIT_NODUP;\n     - \tstruct string_list tracking_refs = STRING_LIST_INIT_DUP;\n     --\tstruct string_list out = STRING_LIST_INIT_DUP;\n     --\tstruct string_list_item *item;\n     - \tstruct forked_cb cb = {\n     - \t\t.remote_names = &remote_names,\n     - \t\t.tracking_refs = &tracking_refs,\n     --\t\t.out = &out,\n     -+\t\t.out = out,\n     - \t};\n     - \n     --\tif (!argc)\n     --\t\tdie(_(\"--forked requires at least one <remote>\"));\n     --\n     - \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n     - \n     - \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     - \t\t\t\t collect_forked_branch, &cb);\n     - \n     --\tstring_list_sort(&out);\n     --\tfor_each_string_list_item(item, &out)\n     --\t\tputs(item->string);\n     -+\tstring_list_sort(out);\n     -+\n     -+\tif (protected_default_refs)\n     -+\t\tcollect_default_branch_refs(&remote_names, protected_default_refs);\n     - \n     - \tstring_list_clear(&remote_names, 0);\n     - \tstring_list_clear(&tracking_refs, 0);\n     -+}\n     -+\n     -+static int list_forked_branches(int argc, const char **argv)\n     -+{\n     -+\tstruct string_list out = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list_item *item;\n     -+\n     -+\tif (!argc)\n     -+\t\tdie(_(\"--forked requires at least one <remote>\"));\n     -+\n     -+\tcollect_forked_set(argc, argv, NULL, &out);\n     -+\tfor_each_string_list_item(item, &out)\n     -+\t\tputs(item->string);\n     -+\n     - \tstring_list_clear(&out, 0);\n     +@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n       \treturn 0;\n       }\n       \n      +static int prune_merged_branches(int argc, const char **argv, int quiet)\n      +{\n     ++\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n      +\tstruct strvec deletable = STRVEC_INIT;\n     ++\tstruct strbuf buf = STRBUF_INIT;\n      +\tstruct string_list_item *item;\n      +\tint n_not_merged = 0;\n      +\tint ret = 0;\n      +\n      +\tif (!argc)\n     -+\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n     ++\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n      +\n     -+\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n     ++\tcollect_forked_set(argc, argv, &candidates);\n     ++\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n      +\n      +\tfor_each_string_list_item(item, &candidates) {\n      +\t\tconst char *short_name = item->string;\n     -+\t\tstruct strbuf full = STRBUF_INIT;\n     -+\t\tstruct branch *branch;\n     -+\t\tconst char *upstream;\n     ++\t\tconst char *upstream = item->util;\n      +\n     -+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     -+\t\tif (branch_checked_out(full.buf)) {\n     -+\t\t\tstrbuf_release(&full);\n     ++\t\tstrbuf_reset(&buf);\n     ++\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n     ++\t\tif (branch_checked_out(buf.buf))\n      +\t\t\tcontinue;\n     -+\t\t}\n     -+\t\tstrbuf_release(&full);\n     -+\n     -+\t\tbranch = branch_get(short_name);\n     -+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     -+\t\tif (!upstream ||\n     -+\t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n     -+\t\t\t\t     upstream))\n     ++\n     ++\t\tif (string_list_has_string(&protected_default_names,\n     ++\t\t\t\t\t   short_name))\n     ++\t\t\tcontinue;\n     ++\n     ++\t\tif (!refs_ref_exists(refs, upstream))\n      +\t\t\tcontinue;\n     -+\t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n     -+\t\t\tconst char *leaf = strrchr(upstream, '/');\n     -+\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n     -+\t\t\t\tcontinue;\n     -+\t\t}\n      +\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n     ++\tstrbuf_release(&buf);\n      +\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n     -+\t\t\t\t      1, 0,\n     -+\t\t\t\t      FILTER_REFS_BRANCHES, quiet,\n     ++\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n      +\t\t\t\t      1, &n_not_merged);\n      +\n      +\tif (n_not_merged && !quiet)\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\t\t\tn_not_merged);\n      +\n      +\tstrvec_clear(&deletable);\n     -+\tstring_list_clear(&candidates, 0);\n     -+\tstring_list_clear(&protected_default_refs, 0);\n     ++\tstring_list_clear(&candidates, 1);\n     ++\tstring_list_clear(&protected_default_names, 0);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t N_(\"edit the description for the branch\")),\n       \t\tOPT_BOOL(0, \"forked\", &forked,\n     - \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n     + \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n      +\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     -+\t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n     ++\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \tif (delete) {\n     - \t\tif (!argc)\n     - \t\t\tdie(_(\"branch name required\"));\n     --\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     -+\t\tret = delete_branches(argc, argv, 0, delete > 1, filter.kind,\n     - \t\t\t\t      quiet, 0, NULL);\n     - \t\tgoto out;\n       \t} else if (forked) {\n       \t\tret = list_forked_branches(argc, argv);\n       \t\tgoto out;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tret = 0;\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>' '\n     - \ttest_grep \"at least one <remote>\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '\n     + \ttest_grep \"at least one <branch>\" err\n       '\n       \n      +test_expect_success '--prune-merged: setup' '\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\tgit -C pm-merged branch two two-commit &&\n      +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n      +\n     -+\tgit -C pm-merged branch --prune-merged origin &&\n     ++\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n      +\n      +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n      +'\n      +\n     ++test_expect_success '--prune-merged with a literal upstream argument' '\n     ++\ttest_when_finished \"rm -rf pm-literal\" &&\n     ++\tgit clone pm-upstream pm-literal &&\n     ++\tgit -C pm-literal branch one one-commit &&\n     ++\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-literal branch keepme one-commit &&\n     ++\tgit -C pm-literal branch --set-upstream-to=origin/main keepme &&\n     ++\n     ++\tgit -C pm-literal branch --prune-merged origin/next &&\n     ++\n     ++\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&\n     ++\tgit -C pm-literal rev-parse --verify refs/heads/keepme\n     ++'\n     ++\n     ++test_expect_success '--prune-merged unions multiple <branch> arguments' '\n     ++\ttest_when_finished \"rm -rf pm-union\" &&\n     ++\tgit clone pm-upstream pm-union &&\n     ++\tgit -C pm-union branch one one-commit &&\n     ++\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-union branch two base &&\n     ++\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n     ++\n     ++\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n     ++\n     ++\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n     ++\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n     ++'\n     ++\n     ++test_expect_success '--prune-merged with a local-branch argument' '\n     ++\ttest_create_repo pm-local &&\n     ++\ttest_when_finished \"rm -rf pm-local\" &&\n     ++\ttest_commit -C pm-local base &&\n     ++\tgit -C pm-local branch topic base &&\n     ++\tgit -C pm-local config branch.topic.remote . &&\n     ++\tgit -C pm-local config branch.topic.merge refs/heads/main &&\n     ++\tgit -C pm-local checkout --detach &&\n     ++\n     ++\tgit -C pm-local branch --prune-merged main &&\n     ++\n     ++\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&\n     ++\tgit -C pm-local rev-parse --verify refs/heads/main\n     ++'\n     ++\n      +test_expect_success '--prune-merged spares branches with un-integrated commits' '\n      +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n      +\tgit clone pm-upstream pm-unmerged &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_commit -C pm-unmerged local-only &&\n      +\tgit -C pm-unmerged checkout - &&\n      +\n     -+\tgit -C pm-unmerged branch --prune-merged origin 2>err &&\n     ++\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n      +\ttest_grep \"not fully merged\" err &&\n      +\ttest_grep \"Skipped 1 branch\" err &&\n      +\ttest_grep \"git branch -D\" err &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n      +\n      +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n     -+\tgit -C pm-upstream-gone branch --prune-merged origin &&\n     ++\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n      +'\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\tgit -C pm-head checkout -b one one-commit &&\n      +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-head branch --prune-merged origin &&\n     ++\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-head rev-parse --verify refs/heads/one\n      +'\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'\n      +\ttest_when_finished \"rm -rf pm-default\" &&\n      +\tgit clone pm-upstream pm-default &&\n      +\tgit -C pm-default checkout --detach &&\n     -+\tgit -C pm-default branch --prune-merged origin &&\n     ++\tgit -C pm-default branch --prune-merged \"origin/*\" &&\n      +\tgit -C pm-default rev-parse --verify refs/heads/main\n      +'\n      +\n     -+test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '\n     ++test_expect_success '--prune-merged protects the default branch by name only' '\n      +\ttest_when_finished \"rm -rf pm-default-alias\" &&\n      +\tgit clone pm-upstream pm-default-alias &&\n      +\tgit -C pm-default-alias branch --track trunk origin/main &&\n      +\tgit -C pm-default-alias checkout --detach &&\n     -+\tgit -C pm-default-alias branch --prune-merged origin &&\n     ++\tgit -C pm-default-alias branch --prune-merged \"origin/*\" &&\n      +\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n      +\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n      +'\n     ++\n     ++test_expect_success '--prune-merged with literal arg also protects default-name' '\n     ++\ttest_when_finished \"rm -rf pm-literal-default\" &&\n     ++\tgit clone pm-upstream pm-literal-default &&\n     ++\tgit -C pm-literal-default checkout --detach &&\n     ++\tgit -C pm-literal-default branch --prune-merged origin/main &&\n     ++\tgit -C pm-literal-default rev-parse --verify refs/heads/main\n     ++'\n     ++\n     ++test_expect_success '--prune-merged requires at least one <branch>' '\n     ++\ttest_must_fail git -C pm-upstream branch --prune-merged 2>err &&\n     ++\ttest_grep \"at least one <branch>\" err\n     ++'\n      +\n       test_done\n 4:  19b6d94fa7 ! 3:  6e38d7af3a branch: add branch.<name>.pruneMerged opt-out\n     @@ Metadata\n       ## Commit message ##\n          branch: add branch.<name>.pruneMerged opt-out\n      \n     -    Setting branch.<name>.pruneMerged=false exempts that branch from\n     -    --prune-merged, even with --force. Useful for keeping a topic\n     -    branch around between rounds.\n     +    Setting branch.<name>.pruneMerged=false exempts that branch\n     +    from --prune-merged. Useful for topic branches you intend to\n     +    develop further after an initial round has been merged\n     +    upstream.\n      \n          Explicit deletion via 'git branch -d' is unaffected.\n      \n     @@ Documentation/config/branch.adoc: for details).\n      +\n      +`branch.<name>.pruneMerged`::\n      +\tIf set to `false`, branch _<name>_ is exempt from\n     -+\t`git branch --prune-merged`.\n     -+\tUseful for topic branches you intend to develop further after\n     -+\tan initial round has been merged upstream. Defaults to true.\n     -+\tExplicit deletion via `git branch -d` is unaffected.\n     ++\t`git branch --prune-merged`. Defaults to true. Explicit\n     ++\tdeletion via `git branch -d` is unaffected.\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: against whatever the remote-tracking refs say locally.\n     - A branch whose upstream no longer resolves locally is left alone\n     - (its disappearance is not, on its own, evidence that the work was\n     - integrated). The currently checked-out branch in any worktree is\n     --always preserved, as is the local branch that mirrors _<remote>_'s\n     -+always preserved, as is any branch with `branch.<name>.pruneMerged`\n     -+set to `false`, and the local branch that mirrors _<remote>_'s\n     - default branch.\n     +@@ Documentation/git-branch.adoc: The following branches are always preserved:\n     +   any configured remote (the target of\n     +   `refs/remotes/<remote>/HEAD`) -- typically `main` or\n     +   `master`;\n     ++* any branch with `branch.<name>.pruneMerged` set to `false`;\n     + * any branch whose upstream no longer resolves locally.\n     + --\n       \n     - `-v`::\n      \n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n       \tfor_each_string_list_item(item, &candidates) {\n       \t\tconst char *short_name = item->string;\n     - \t\tstruct strbuf full = STRBUF_INIT;\n     -+\t\tstruct strbuf key = STRBUF_INIT;\n     - \t\tstruct branch *branch;\n     - \t\tconst char *upstream;\n     -+\t\tint opt_out = 0;\n     + \t\tconst char *upstream = item->util;\n     ++\t\tint prune_allowed = 1;\n       \n     - \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     - \t\tif (branch_checked_out(full.buf)) {\n     - \t\t\tstrbuf_release(&full);\n     -+\t\t\tstrbuf_release(&key);\n     - \t\t\tcontinue;\n     - \t\t}\n     - \t\tstrbuf_release(&full);\n     + \t\tstrbuf_reset(&buf);\n     + \t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     - \t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     - \t\tif (!upstream ||\n     - \t\t    !refs_ref_exists(get_main_ref_store(the_repository),\n     --\t\t\t\t     upstream))\n     -+\t\t\t\t     upstream)) {\n     -+\t\t\tstrbuf_release(&key);\n     + \t\tif (!refs_ref_exists(refs, upstream))\n       \t\t\tcontinue;\n     -+\t\t}\n     - \t\tif (string_list_has_string(&protected_default_refs, upstream)) {\n     - \t\t\tconst char *leaf = strrchr(upstream, '/');\n     --\t\t\tif (leaf && !strcmp(leaf + 1, short_name))\n     -+\t\t\tif (leaf && !strcmp(leaf + 1, short_name)) {\n     -+\t\t\t\tstrbuf_release(&key);\n     - \t\t\t\tcontinue;\n     -+\t\t\t}\n     -+\t\t}\n     -+\n     -+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n     -+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n     -+\t\t    !opt_out) {\n     + \n     ++\t\tstrbuf_reset(&buf);\n     ++\t\tstrbuf_addf(&buf, \"branch.%s.prunemerged\", short_name);\n     ++\t\tif (!repo_config_get_bool(the_repository, buf.buf,\n     ++\t\t\t\t\t  &prune_allowed) &&\n     ++\t\t    !prune_allowed) {\n      +\t\t\tif (!quiet)\n      +\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n      +\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n     -+\t\t\tstrbuf_release(&key);\n      +\t\t\tcontinue;\n     - \t\t}\n     -+\t\tstrbuf_release(&key);\n     - \n     ++\t\t}\n     ++\n       \t\tstrvec_push(&deletable, short_name);\n       \t}\n     + \tstrbuf_release(&buf);\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default branch by name, no\n     - \ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '\n     + \ttest_grep \"at least one <branch>\" err\n       '\n       \n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default\n      +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n      +\tgit -C pm-optout config branch.one.pruneMerged false &&\n      +\n     -+\tgit -C pm-optout branch --prune-merged origin 2>err &&\n     ++\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n      +\n      +\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n 5:  6ae95d3f98 ! 4:  c68d162e22 branch: add --all-remotes flag\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --all-remotes flag\n     +    branch: add --dry-run for --prune-merged\n      \n     -    Combined with --forked or --prune-merged, --all-remotes acts on\n     -    every configured remote, in addition to any explicit <remote>\n     -    arguments. Used alone, it errors out.\n     +    With --dry-run, --prune-merged prints the branches it would\n     +    delete and exits without touching any ref. Useful for\n     +    sanity-checking a glob like 'origin/*' before letting it run.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n     - git branch (-c|-C) [<old-branch>] <new-branch>\n     +@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     --git branch --forked <remote>...\n     --git branch --prune-merged <remote>...\n     -+git branch --forked (<remote>... | --all-remotes)\n     -+git branch --prune-merged (<remote>... | --all-remotes)\n     + git branch --forked <branch>...\n     +-git branch --prune-merged <branch>...\n     ++git branch --prune-merged [--dry-run] <branch>...\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: always preserved, as is any branch with `branch.<name>.pruneMerged`\n     - set to `false`, and the local branch that mirrors _<remote>_'s\n     - default branch.\n     +@@ Documentation/git-branch.adoc: The following branches are always preserved:\n     + * any branch whose upstream no longer resolves locally.\n     + --\n       \n     -+`--all-remotes`::\n     -+\tWith `--forked` or `--prune-merged`, act on every\n     -+\tconfigured remote in addition to any explicit _<remote>_\n     -+\targuments.\n     ++`--dry-run`::\n     ++\tWith `--prune-merged`, print the branches that would be\n     ++\tdeleted instead of deleting them.\n      +\n       `-v`::\n       `-vv`::\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n     - \tfree_worktrees(worktrees);\n     +@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     + \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     + \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     + \tN_(\"git branch [<options>] --forked <branch>...\"),\n     +-\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n     ++\tN_(\"git branch [<options>] --prune-merged [--dry-run] <branch>...\"),\n     + \tNULL\n     + };\n     + \n     +@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       }\n       \n     -+static int collect_remote_name(struct remote *remote, void *cb_data)\n     -+{\n     -+\tstruct string_list *remote_names = cb_data;\n     -+\tstring_list_insert(remote_names, remote->name);\n     -+\treturn 0;\n     -+}\n     -+\n     - static void parse_forked_args(int argc, const char **argv,\n     - \t\t\t      struct string_list *remote_names,\n     - \t\t\t      struct string_list *tracking_refs)\n     -@@ builtin/branch.c: static void collect_default_branch_refs(const struct string_list *remote_names,\n     - \t}\n     - }\n     - \n     --static void collect_forked_set(int argc, const char **argv,\n     -+static void collect_forked_set(int argc, const char **argv, int all_remotes,\n     - \t\t\t       struct string_list *protected_default_refs,\n     - \t\t\t       struct string_list *out)\n     - {\n     -@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n     - \t};\n     - \n     - \tparse_forked_args(argc, argv, &remote_names, &tracking_refs);\n     -+\tif (all_remotes)\n     -+\t\tfor_each_remote(collect_remote_name, &remote_names);\n     - \n     - \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     - \t\t\t\t collect_forked_branch, &cb);\n     -@@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,\n     - \tstring_list_clear(&tracking_refs, 0);\n     - }\n     - \n     --static int list_forked_branches(int argc, const char **argv)\n     -+static int list_forked_branches(int argc, const char **argv, int all_remotes)\n     + static int delete_branches(int argc, const char **argv, int force, int kinds,\n     +-\t\t\t   int quiet, int warn_only, int *n_not_merged)\n     ++\t\t\t   int quiet, int warn_only, int dry_run,\n     ++\t\t\t   int *n_not_merged)\n       {\n     - \tstruct string_list out = STRING_LIST_INIT_DUP;\n     - \tstruct string_list_item *item;\n     - \n     --\tif (!argc)\n     --\t\tdie(_(\"--forked requires at least one <remote>\"));\n     -+\tif (!argc && !all_remotes)\n     -+\t\tdie(_(\"--forked requires at least one <remote> or --all-remotes\"));\n     - \n     --\tcollect_forked_set(argc, argv, NULL, &out);\n     -+\tcollect_forked_set(argc, argv, all_remotes, NULL, &out);\n     - \tfor_each_string_list_item(item, &out)\n     - \t\tputs(item->string);\n     - \n     + \tstruct commit *head_rev = NULL;\n     + \tstruct object_id oid;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\t\tgoto next;\n     + \t\t}\n     + \n     ++\t\tif (dry_run) {\n     ++\t\t\tprintf(_(\"Would delete branch '%s'\\n\"),\n     ++\t\t\t       name + branch_name_pos);\n     ++\t\t\tgoto next;\n     ++\t\t}\n     ++\n     + \t\titem = string_list_append(&refs_to_delete, name);\n     + \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n     + \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n      @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n       \treturn 0;\n       }\n       \n      -static int prune_merged_branches(int argc, const char **argv, int quiet)\n      +static int prune_merged_branches(int argc, const char **argv,\n     -+\t\t\t\t int all_remotes, int quiet)\n     ++\t\t\t\t int dry_run, int quiet)\n       {\n     + \tstruct ref_store *refs = get_main_ref_store(the_repository);\n       \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     - \tstruct string_list protected_default_refs = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     - \tint n_not_merged = 0;\n     - \tint ret = 0;\n     - \n     --\tif (!argc)\n     --\t\tdie(_(\"--prune-merged requires at least one <remote>\"));\n     -+\tif (!argc && !all_remotes)\n     -+\t\tdie(_(\"--prune-merged requires at least one <remote> or --all-remotes\"));\n     - \n     --\tcollect_forked_set(argc, argv, &protected_default_refs, &candidates);\n     -+\tcollect_forked_set(argc, argv, all_remotes, &protected_default_refs,\n     -+\t\t\t   &candidates);\n     - \n     - \tfor_each_string_list_item(item, &candidates) {\n     - \t\tconst char *short_name = item->string;\n     + \tif (deletable.nr)\n     + \t\tret = delete_branches(deletable.nr, deletable.v,\n     + \t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n     +-\t\t\t\t      1, &n_not_merged);\n     ++\t\t\t\t      1, dry_run, &n_not_merged);\n     + \n     + \tif (n_not_merged && !quiet)\n     + \t\tfprintf(stderr,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n       \tint forked = 0;\n       \tint prune_merged = 0;\n     -+\tint all_remotes = 0;\n     ++\tint dry_run = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\tN_(\"list local branches forked from the given <remote>s\")),\n     + \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n       \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     - \t\t\tN_(\"delete local branches forked from the given <remote>s that are merged into their upstream\")),\n     -+\t\tOPT_BOOL_F(0, \"all-remotes\", &all_remotes,\n     -+\t\t\tN_(\"with --forked or --prune-merged, act on every configured remote\"),\n     -+\t\t\tPARSE_OPT_NONEG),\n     + \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n     ++\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n     ++\t\t\tN_(\"with --prune-merged, only print what would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n       \t\t\t     0);\n       \n     -+\tif (all_remotes && !forked && !prune_merged)\n     -+\t\tdie(_(\"--all-remotes requires --forked or --prune-merged\"));\n     -+\n     ++\tif (dry_run && !prune_merged)\n     ++\t\tdie(_(\"--dry-run requires --prune-merged\"));\n      +\n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n       \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n       \t    argc == 0)\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\t\t      quiet, 0, NULL);\n     + \t\tif (!argc)\n     + \t\t\tdie(_(\"branch name required\"));\n     + \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     +-\t\t\t\t      quiet, 0, NULL);\n     ++\t\t\t\t      quiet, 0, 0, NULL);\n       \t\tgoto out;\n       \t} else if (forked) {\n     --\t\tret = list_forked_branches(argc, argv);\n     -+\t\tret = list_forked_branches(argc, argv, all_remotes);\n     + \t\tret = list_forked_branches(argc, argv);\n       \t\tgoto out;\n       \t} else if (prune_merged) {\n      -\t\tret = prune_merged_branches(argc, argv, quiet);\n     -+\t\tret = prune_merged_branches(argc, argv, all_remotes, quiet);\n     ++\t\tret = prune_merged_branches(argc, argv, dry_run, quiet);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>' '\n     - \ttest_grep \"at least one <remote>\" err\n     - '\n     - \n     -+test_expect_success '--forked --all-remotes covers every configured remote' '\n     -+\tgit -C forked branch --forked --all-remotes >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     -+\tlocal-foreign\n     -+\tlocal-one\n     -+\tlocal-two\n     -+\tmain\n     -+\tEOF\n     -+\ttest_cmp expect actual\n     -+'\n     -+\n     -+test_expect_success '--forked --all-remotes still validates explicit <remote>' '\n     -+\ttest_must_fail git -C forked branch --forked nope --all-remotes 2>err &&\n     -+\ttest_grep \"neither a configured remote nor a remote-tracking branch\" err\n     -+'\n     -+\n     -+test_expect_success '--all-remotes alone is rejected' '\n     -+\ttest_must_fail git -C forked branch --all-remotes 2>err &&\n     -+\ttest_grep \"requires --forked or --prune-merged\" err\n     -+'\n     -+\n     - test_expect_success '--prune-merged: setup' '\n     - \ttest_create_repo pm-upstream &&\n     - \ttest_commit -C pm-upstream base &&\n      @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n       \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n       '\n       \n     -+test_expect_success '--prune-merged --all-remotes covers every configured remote' '\n     -+\ttest_when_finished \"rm -rf pm-allremotes pm-other\" &&\n     -+\tgit clone pm-upstream pm-allremotes &&\n     -+\ttest_create_repo pm-other &&\n     -+\ttest_commit -C pm-other other-base &&\n     -+\tgit -C pm-other checkout -b stable &&\n     -+\ttest_commit -C pm-other foreign-commit &&\n     -+\tgit -C pm-other branch foreign HEAD &&\n     -+\tgit -C pm-other checkout main &&\n     ++test_expect_success '--prune-merged --dry-run prints but does not delete' '\n     ++\ttest_when_finished \"rm -rf pm-dryrun\" &&\n     ++\tgit clone pm-upstream pm-dryrun &&\n     ++\tgit -C pm-dryrun branch one one-commit &&\n     ++\tgit -C pm-dryrun branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-allremotes remote add other ../pm-other &&\n     -+\tgit -C pm-allremotes fetch other &&\n     -+\tgit -C pm-allremotes branch one one-commit &&\n     -+\tgit -C pm-allremotes branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-allremotes branch foreign other/foreign &&\n     -+\tgit -C pm-allremotes branch --set-upstream-to=other/stable foreign &&\n     ++\tgit -C pm-dryrun branch --prune-merged --dry-run \"origin/*\" >out &&\n     ++\ttest_grep \"Would delete branch .one.\" out &&\n     ++\tgit -C pm-dryrun rev-parse --verify refs/heads/one\n     ++'\n     ++\n     ++test_expect_success '--prune-merged --dry-run skips un-integrated branches' '\n     ++\ttest_when_finished \"rm -rf pm-dryrun-unmerged\" &&\n     ++\tgit clone pm-upstream pm-dryrun-unmerged &&\n     ++\tgit -C pm-dryrun-unmerged checkout -b wip origin/next &&\n     ++\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&\n     ++\ttest_commit -C pm-dryrun-unmerged local-only &&\n     ++\tgit -C pm-dryrun-unmerged checkout - &&\n     ++\tgit -C pm-dryrun-unmerged branch merged one-commit &&\n     ++\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&\n      +\n     -+\tgit -C pm-allremotes branch --prune-merged --all-remotes &&\n     ++\tgit -C pm-dryrun-unmerged branch --prune-merged --dry-run \"origin/*\" \\\n     ++\t\t>out 2>err &&\n     ++\ttest_grep \"Would delete branch .merged.\" out &&\n     ++\ttest_grep ! \"Would delete branch .wip.\" out &&\n     ++\ttest_grep \"not fully merged\" err &&\n     ++\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&\n     ++\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged\n     ++'\n      +\n     -+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/one &&\n     -+\ttest_must_fail git -C pm-allremotes rev-parse --verify refs/heads/foreign\n     ++test_expect_success '--dry-run requires --prune-merged' '\n     ++\ttest_must_fail git -C pm-upstream branch --dry-run 2>err &&\n     ++\ttest_grep \"requires --prune-merged\" err\n      +'\n      +\n       test_done\n\n-- \ngitgitgadget\n"},{"id":"543854","messageId":"f2df15983067ce39b6c33ab81115863d5c3567f4.1779403204.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","subject":"[PATCH v10 1/4] branch: add --forked <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-21T22:40:01Z","receivedAt":"2026-05-21T22:40:09Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --forked <branch>...\n\nlists local branches whose configured upstream matches any\nof the given <branch> arguments.\n\nEach <branch> is resolved to the same kind of ref that\nbranch.<name>.remote and branch.<name>.merge together point at:\na remote-tracking branch (e.g. origin/master), or, for branches\ntracking a local upstream, a local branch (e.g. master).\nShell-style globs are also accepted (e.g. 'origin/*'). Multiple\narguments are unioned.\n\nThis is the building block for --prune-merged.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |   7 ++\n builtin/branch.c              | 137 +++++++++++++++++++++++++++++++---\n t/t3200-branch.sh             |  81 ++++++++++++++++++++\n 3 files changed, 214 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..3a421f6663 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <branch>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches whose configured upstream matches any\n+\tof the given _<branch>_ arguments. Each argument is either\n+\ta ref (e.g. `origin/master`, `master`) or a shell-style\n+\tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..1e24c95a69 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -28,6 +28,7 @@\n #include \"help.h\"\n #include \"advice.h\"\n #include \"commit-reach.h\"\n+#include \"wildmatch.h\"\n \n static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <branch>...\"),\n \tNULL\n };\n \n@@ -191,7 +193,8 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only,\n+\t\t\t       int *n_not_merged)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -199,10 +202,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n+\t\tif (n_not_merged)\n+\t\t\t(*n_not_merged)++;\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -218,7 +229,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -308,8 +319,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -673,6 +685,102 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct string_list *upstream_patterns)\n+{\n+\tint i;\n+\n+\tfor (i = 0; i < argc; i++) {\n+\t\tconst char *arg = argv[i];\n+\t\tstruct object_id oid;\n+\t\tchar *full_ref = NULL;\n+\t\tconst char *short_ref;\n+\n+\t\tif (has_glob_specials(arg)) {\n+\t\t\tstring_list_insert(upstream_patterns, arg);\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t\t  &full_ref, 0) == 1 &&\n+\t\t    (skip_prefix(full_ref, \"refs/heads/\", &short_ref) ||\n+\t\t     skip_prefix(full_ref, \"refs/remotes/\", &short_ref))) {\n+\t\t\tstring_list_insert(upstream_patterns, short_ref);\n+\t\t\tfree(full_ref);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tfree(full_ref);\n+\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\t}\n+}\n+\n+struct forked_cb {\n+\tconst struct string_list *upstream_patterns;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\tstruct branch *branch;\n+\tconst char *upstream, *short_upstream;\n+\tconst struct string_list_item *item;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tbranch = branch_get(ref->name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\tshort_upstream = upstream;\n+\t(void)(skip_prefix(short_upstream, \"refs/heads/\", &short_upstream) ||\n+\t       skip_prefix(short_upstream, \"refs/remotes/\", &short_upstream));\n+\n+\tfor_each_string_list_item(item, cb->upstream_patterns)\n+\t\tif (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {\n+\t\t\tstring_list_append(cb->out, ref->name)->util =\n+\t\t\t\txstrdup(upstream);\n+\t\t\treturn 0;\n+\t\t}\n+\treturn 0;\n+}\n+\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *out)\n+{\n+\tstruct string_list upstream_patterns = STRING_LIST_INIT_DUP;\n+\tstruct forked_cb cb = {\n+\t\t.upstream_patterns = &upstream_patterns,\n+\t\t.out = out,\n+\t};\n+\n+\tparse_forked_args(argc, argv, &upstream_patterns);\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_clear(&upstream_patterns, 0);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <branch>\"));\n+\n+\tcollect_forked_set(argc, argv, &out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tstring_list_clear(&out, 1);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +822,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +876,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +922,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +931,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -858,7 +969,11 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0, NULL);\n+\t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..45455cb8ce 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,85 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached &&\n+\tgit -C forked branch --track topic-on-main main\n+'\n+\n+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '\n+\tgit -C forked branch --forked main >actual &&\n+\techo topic-on-main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> matches every upstream under the pattern' '\n+\tgit -C forked branch --forked \"origin/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <branch> arguments' '\n+\tgit -C forked branch --forked origin/one other/foreign >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked main \"other/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\ttopic-on-main\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <branch>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543856","messageId":"718e28c7e0120a826385189213cccec1f0fce1af.1779403204.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","subject":"[PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-21T22:40:02Z","receivedAt":"2026-05-21T22:40:10Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --prune-merged <branch>...\n\ndeletes the local branches that --forked <branch> would list,\nbut only those whose tip is reachable from their configured\nupstream: the work has already landed on the upstream the\nbranch tracks, so the local copy is no longer needed.\n\nThe following branches are always preserved:\n\n* the currently checked-out branch in any worktree;\n* any local branch whose name matches the default branch of\n  any configured remote (the target of\n  refs/remotes/<remote>/HEAD) -- typically 'main' or\n  'master';\n* any branch whose upstream no longer resolves locally.\n\nReachability is read from whatever branch.<name>.merge\nresolves to locally, which is usually a remote-tracking ref\nbut may also be a local branch. When the upstream is a\nremote-tracking ref, the natural workflow is\n\n\tgit fetch <remote>\n\tgit branch --prune-merged <upstream-pattern>\n\nso the upstream reflects the current state before pruning.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  21 +++++\n builtin/branch.c              |  93 ++++++++++++++++++++++-\n t/t3200-branch.sh             | 139 ++++++++++++++++++++++++++++++++++\n 3 files changed, 249 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 3a421f6663..a7c0e29e94 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <branch>...\n+git branch --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -206,6 +207,26 @@ This option is only applicable in non-verbose mode.\n \ta ref (e.g. `origin/master`, `master`) or a shell-style\n \tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for\n+\tthe same _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream.\n++\n+For arguments that refer to remote-tracking branches, run\n+`git fetch` first so reachability is checked against the\n+current upstream state; refs are read locally.\n++\n+The following branches are always preserved:\n++\n+--\n+* the currently checked-out branch in any worktree;\n+* any local branch whose name matches the default branch of\n+  any configured remote (the target of\n+  `refs/remotes/<remote>/HEAD`) -- typically `main` or\n+  `master`;\n+* any branch whose upstream no longer resolves locally.\n+--\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1e24c95a69..29d38e9060 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strvec.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -40,6 +41,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n \tN_(\"git branch [<options>] --forked <branch>...\"),\n+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n \tNULL\n };\n \n@@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,\n \t * any of the following code, but during the transition period,\n \t * a gentle reminder is in order.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n+static int collect_default_branch_name(struct remote *remote, void *cb_data)\n+{\n+\tstruct string_list *protected = cb_data;\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct strbuf head = STRBUF_INIT;\n+\tconst char *target;\n+\n+\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n+\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n+\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n+\tif (target) {\n+\t\tconst char *leaf = strrchr(target, '/');\n+\t\tif (leaf)\n+\t\t\tstring_list_insert(protected, leaf + 1);\n+\t}\n+\tstrbuf_release(&head);\n+\treturn 0;\n+}\n+\n static void collect_forked_set(int argc, const char **argv,\n \t\t\t       struct string_list *out)\n {\n@@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct strbuf buf = STRBUF_INIT;\n+\tstruct string_list_item *item;\n+\tint n_not_merged = 0;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n+\n+\tcollect_forked_set(argc, argv, &candidates);\n+\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tconst char *upstream = item->util;\n+\n+\t\tstrbuf_reset(&buf);\n+\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n+\t\tif (branch_checked_out(buf.buf))\n+\t\t\tcontinue;\n+\n+\t\tif (string_list_has_string(&protected_default_names,\n+\t\t\t\t\t   short_name))\n+\t\t\tcontinue;\n+\n+\t\tif (!refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\tstrbuf_release(&buf);\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n+\t\t\t\t      1, &n_not_merged);\n+\n+\tif (n_not_merged && !quiet)\n+\t\tfprintf(stderr,\n+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n+\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n+\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n+\t\t\t   n_not_merged),\n+\t\t\tn_not_merged);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 1);\n+\tstring_list_clear(&protected_default_names, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -823,6 +901,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -878,6 +957,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -922,7 +1003,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -931,7 +1013,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -975,6 +1057,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 45455cb8ce..c8589cd3a6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1798,4 +1798,143 @@ test_expect_success '--forked requires at least one <branch>' '\n \ttest_grep \"at least one <branch>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged with a literal upstream argument' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-literal branch keepme one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/main keepme &&\n+\n+\tgit -C pm-literal branch --prune-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&\n+\tgit -C pm-literal rev-parse --verify refs/heads/keepme\n+'\n+\n+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\n+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged with a local-branch argument' '\n+\ttest_create_repo pm-local &&\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\ttest_commit -C pm-local base &&\n+\tgit -C pm-local branch topic base &&\n+\tgit -C pm-local config branch.topic.remote . &&\n+\tgit -C pm-local config branch.topic.merge refs/heads/main &&\n+\tgit -C pm-local checkout --detach &&\n+\n+\tgit -C pm-local branch --prune-merged main &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&\n+\tgit -C pm-local rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged spares branches with un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep \"Skipped 1 branch\" err &&\n+\ttest_grep \"git branch -D\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares the local default branch' '\n+\ttest_when_finished \"rm -rf pm-default\" &&\n+\tgit clone pm-upstream pm-default &&\n+\tgit -C pm-default checkout --detach &&\n+\tgit -C pm-default branch --prune-merged \"origin/*\" &&\n+\tgit -C pm-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged protects the default branch by name only' '\n+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n+\tgit clone pm-upstream pm-default-alias &&\n+\tgit -C pm-default-alias branch --track trunk origin/main &&\n+\tgit -C pm-default-alias checkout --detach &&\n+\tgit -C pm-default-alias branch --prune-merged \"origin/*\" &&\n+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n+'\n+\n+test_expect_success '--prune-merged with literal arg also protects default-name' '\n+\ttest_when_finished \"rm -rf pm-literal-default\" &&\n+\tgit clone pm-upstream pm-literal-default &&\n+\tgit -C pm-literal-default checkout --detach &&\n+\tgit -C pm-literal-default branch --prune-merged origin/main &&\n+\tgit -C pm-literal-default rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged requires at least one <branch>' '\n+\ttest_must_fail git -C pm-upstream branch --prune-merged 2>err &&\n+\ttest_grep \"at least one <branch>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543857","messageId":"6e38d7af3a08a774f33fdf3e5ab5e22a530ff5dd.1779403204.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","subject":"[PATCH v10 3/4] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-21T22:40:03Z","receivedAt":"2026-05-21T22:40:12Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch\nfrom --prune-merged. Useful for topic branches you intend to\ndevelop further after an initial round has been merged\nupstream.\n\nExplicit deletion via 'git branch -d' is unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  5 +++++\n Documentation/git-branch.adoc    |  1 +\n builtin/branch.c                 | 13 +++++++++++++\n t/t3200-branch.sh                | 27 +++++++++++++++++++++++++++\n 4 files changed, 46 insertions(+)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..6402b78a73 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,8 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`. Defaults to true. Explicit\n+\tdeletion via `git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex a7c0e29e94..247e4daeb8 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -224,6 +224,7 @@ The following branches are always preserved:\n   any configured remote (the target of\n   `refs/remotes/<remote>/HEAD`) -- typically `main` or\n   `master`;\n+* any branch with `branch.<name>.pruneMerged` set to `false`;\n * any branch whose upstream no longer resolves locally.\n --\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 29d38e9060..f995f257f0 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -822,6 +822,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \tfor_each_string_list_item(item, &candidates) {\n \t\tconst char *short_name = item->string;\n \t\tconst char *upstream = item->util;\n+\t\tint prune_allowed = 1;\n \n \t\tstrbuf_reset(&buf);\n \t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n@@ -835,6 +836,18 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \t\tif (!refs_ref_exists(refs, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&buf);\n+\t\tstrbuf_addf(&buf, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, buf.buf,\n+\t\t\t\t\t  &prune_allowed) &&\n+\t\t    !prune_allowed) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \tstrbuf_release(&buf);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex c8589cd3a6..b35189ce84 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1937,4 +1937,31 @@ test_expect_success '--prune-merged requires at least one <branch>' '\n \ttest_grep \"at least one <branch>\" err\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\tgit -C pm-optout config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543858","messageId":"c68d162e22ffb3544a65ff7ae6f55eb6105c80ee.1779403204.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","subject":"[PATCH v10 4/4] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-21T22:40:04Z","receivedAt":"2026-05-21T22:40:13Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --prune-merged prints the branches it would\ndelete and exits without touching any ref. Useful for\nsanity-checking a glob like 'origin/*' before letting it run.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  6 +++++-\n builtin/branch.c              | 26 ++++++++++++++++++++------\n t/t3200-branch.sh             | 35 +++++++++++++++++++++++++++++++++++\n 3 files changed, 60 insertions(+), 7 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 247e4daeb8..349fbfc420 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <branch>...\n-git branch --prune-merged <branch>...\n+git branch --prune-merged [--dry-run] <branch>...\n \n DESCRIPTION\n -----------\n@@ -228,6 +228,10 @@ The following branches are always preserved:\n * any branch whose upstream no longer resolves locally.\n --\n \n+`--dry-run`::\n+\tWith `--prune-merged`, print the branches that would be\n+\tdeleted instead of deleting them.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f995f257f0..b89fd56112 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -41,7 +41,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n \tN_(\"git branch [<options>] --forked <branch>...\"),\n-\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n+\tN_(\"git branch [<options>] --prune-merged [--dry-run] <branch>...\"),\n \tNULL\n };\n \n@@ -231,7 +231,8 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet, int warn_only, int *n_not_merged)\n+\t\t\t   int quiet, int warn_only, int dry_run,\n+\t\t\t   int *n_not_merged)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -327,6 +328,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tgoto next;\n \t\t}\n \n+\t\tif (dry_run) {\n+\t\t\tprintf(_(\"Would delete branch '%s'\\n\"),\n+\t\t\t       name + branch_name_pos);\n+\t\t\tgoto next;\n+\t\t}\n+\n \t\titem = string_list_append(&refs_to_delete, name);\n \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n@@ -802,7 +809,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int quiet)\n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int dry_run, int quiet)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n@@ -855,7 +863,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \tif (deletable.nr)\n \t\tret = delete_branches(deletable.nr, deletable.v,\n \t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n-\t\t\t\t      1, &n_not_merged);\n+\t\t\t\t      1, dry_run, &n_not_merged);\n \n \tif (n_not_merged && !quiet)\n \t\tfprintf(stderr,\n@@ -915,6 +923,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -972,6 +981,8 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --prune-merged, only print what would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1015,6 +1026,9 @@ int cmd_branch(int argc,\n \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n \t\t\t     0);\n \n+\tif (dry_run && !prune_merged)\n+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n+\n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n \t    argc == 0)\n@@ -1065,13 +1079,13 @@ int cmd_branch(int argc,\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n-\t\t\t\t      quiet, 0, NULL);\n+\t\t\t\t      quiet, 0, 0, NULL);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tret = prune_merged_branches(argc, argv, dry_run, quiet);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b35189ce84..908b184e81 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1964,4 +1964,39 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --dry-run prints but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dryrun\" &&\n+\tgit clone pm-upstream pm-dryrun &&\n+\tgit -C pm-dryrun branch one one-commit &&\n+\tgit -C pm-dryrun branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-dryrun branch --prune-merged --dry-run \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch .one.\" out &&\n+\tgit -C pm-dryrun rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '\n+\ttest_when_finished \"rm -rf pm-dryrun-unmerged\" &&\n+\tgit clone pm-upstream pm-dryrun-unmerged &&\n+\tgit -C pm-dryrun-unmerged checkout -b wip origin/next &&\n+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dryrun-unmerged local-only &&\n+\tgit -C pm-dryrun-unmerged checkout - &&\n+\tgit -C pm-dryrun-unmerged branch merged one-commit &&\n+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dryrun-unmerged branch --prune-merged --dry-run \"origin/*\" \\\n+\t\t>out 2>err &&\n+\ttest_grep \"Would delete branch .merged.\" out &&\n+\ttest_grep ! \"Would delete branch .wip.\" out &&\n+\ttest_grep \"not fully merged\" err &&\n+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run requires --prune-merged' '\n+\ttest_must_fail git -C pm-upstream branch --dry-run 2>err &&\n+\ttest_grep \"requires --prune-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543866","messageId":"xmqqmrxsxmxp.fsf@gitster.g","threadId":"65574","inReplyTo":"718e28c7e0120a826385189213cccec1f0fce1af.1779403204.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T01:17:22Z","receivedAt":"2026-05-22T01:17:25Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +`--prune-merged`::\n> +\tDelete the local branches that `--forked` would list for\n> +\tthe same _<branch>_ arguments, but only those whose tip is\n> +\treachable from their configured upstream.\n> ++\n> +For arguments that refer to remote-tracking branches, run\n> +`git fetch` first ...\n\nPlease don't.  \n\n\"git branch -d <derived>\" checks if <derived> has already been\nmerged to its <upstream> as your repository currently sees it, and\nthis makes \"--prune-merged\" inconsistent.  Before deciding to use\n\"--prune-merged\", the user may have sanity checked if it is safe to\nremove by running \"git branch -no-merged\", whose answer hence user's\nsanity checking will be invalidated by your auto-update from the\nupstream.\n\nIt also means that you cannot prune already merged ones while you\nare not online.\n\n"},{"id":"543867","messageId":"xmqq33zkxlaj.fsf@gitster.g","threadId":"65574","inReplyTo":"f2df15983067ce39b6c33ab81115863d5c3567f4.1779403204.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 1/4] branch: add --forked <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T01:52:52Z","receivedAt":"2026-05-22T01:52:55Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1572a4f9ef..1e24c95a69 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -28,6 +28,7 @@\n>  #include \"help.h\"\n>  #include \"advice.h\"\n>  #include \"commit-reach.h\"\n> +#include \"wildmatch.h\"\n>  \n>  static const char * const builtin_branch_usage[] = {\n>  \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n> @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n>  \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>  \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>  \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n> +\tN_(\"git branch [<options>] --forked <branch>...\"),\n>  \tNULL\n>  };\n>  \n> @@ -191,7 +193,8 @@ static int branch_merged(int kind, const char *name,\n>  \n>  static int check_branch_commit(const char *branchname, const char *refname,\n>  \t\t\t       const struct object_id *oid, struct commit *head_rev,\n> -\t\t\t       int kinds, int force)\n> +\t\t\t       int kinds, int force, int warn_only,\n> +\t\t\t       int *n_not_merged)\n>  {\n>  \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n>  \tif (!force && !rev) {\n> @@ -199,10 +202,18 @@ static int check_branch_commit(const char *branchname, const char *refname,\n>  \t\treturn -1;\n>  \t}\n>  \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n> -\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n> -\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> -\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> -\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\tif (warn_only) {\n> +\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t\tbranchname);\n> +\t\t} else {\n> +\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t      branchname);\n> +\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> +\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> +\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\t}\n> +\t\tif (n_not_merged)\n> +\t\t\t(*n_not_merged)++;\n>  \t\treturn -1;\n>  \t}\n>  \treturn 0;\n\nThis function originally was to see if \"git branch -d <derived>\" is\nallowed to remove it by calling branch_merged(), which uses the\nupstream association of <derived>, falling back to head_rev.  For\nour purpose of \"--forked\" check, we are not deleting, so existing\ncalls to error() and the phrasing used in its messages that talks\nabout deletion are not appropriate.  Hence we introduce the\nwarn_only mode to just state the fact the branch is not fully merged\nto its upstream.\n\nIt is inherited from the original code, and it is outside the scope\nof this series, but after the dust settles, we may want to consider\nsaying what other branch this branch is expected to be merged to\n(i.e. \"not fully merged to %s\").\n\nIt is unclear how n_not_merged would be useful.  Wouldn't the caller\nbe capable to count the failure returns from this function?\n\n> @@ -218,7 +229,7 @@ static void delete_branch_config(const char *branchname)\n>  }\n>  \n>  static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet)\n> +\t\t\t   int quiet, int warn_only, int *n_not_merged)\n\nIt is strange to see any need to touch delete_branches() when the\nonly thing you are adding is the \"--forked\" option.  For that\nmatter, the change to check_branch_commit() is also suspect.\n\nIn short, this does a lot more than necessary to add \"--forked\".\nWhy such a split in the series?\n\n> +static void parse_forked_args(int argc, const char **argv,\n> +\t\t\t      struct string_list *upstream_patterns)\n> +{\n> +\tint i;\n> +\n> +\tfor (i = 0; i < argc; i++) {\n> +\t\tconst char *arg = argv[i];\n> +\t\tstruct object_id oid;\n> +\t\tchar *full_ref = NULL;\n> +\t\tconst char *short_ref;\n> +\n> +\t\tif (has_glob_specials(arg)) {\n> +\t\t\tstring_list_insert(upstream_patterns, arg);\n> +\t\t\tcontinue;\n> +\t\t}\n> +\n> +\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n> +\t\t\t\t  &full_ref, 0) == 1 &&\n> +\t\t    (skip_prefix(full_ref, \"refs/heads/\", &short_ref) ||\n> +\t\t     skip_prefix(full_ref, \"refs/remotes/\", &short_ref))) {\n> +\t\t\tstring_list_insert(upstream_patterns, short_ref);\n> +\t\t\tfree(full_ref);\n> +\t\t\tcontinue;\n> +\t\t}\n> +\t\tfree(full_ref);\n> +\n> +\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n> +\t}\n> +}\n\nThis one does look like very much relevant to \"--forked\".\n\n> +struct forked_cb {\n> +\tconst struct string_list *upstream_patterns;\n> +\tstruct string_list *out;\n> +};\n\nSo does this.\n\n> +static int collect_forked_branch(const struct reference *ref, void *cb_data)\n> +{\n> +\tstruct forked_cb *cb = cb_data;\n> +\tstruct branch *branch;\n> +\tconst char *upstream, *short_upstream;\n> +\tconst struct string_list_item *item;\n> +\n> +\tif (ref->flags & REF_ISSYMREF)\n> +\t\treturn 0;\n> +\tbranch = branch_get(ref->name);\n> +\tif (!branch)\n> +\t\treturn 0;\n> +\tupstream = branch_get_upstream(branch, NULL);\n> +\tif (!upstream)\n> +\t\treturn 0;\n> +\tshort_upstream = upstream;\n> +\t(void)(skip_prefix(short_upstream, \"refs/heads/\", &short_upstream) ||\n> +\t       skip_prefix(short_upstream, \"refs/remotes/\", &short_upstream));\n> +\n> +\tfor_each_string_list_item(item, cb->upstream_patterns)\n\nSholdn't each element of upstream_patterns remember if it is\nsuitable for wildmatch or not when it gets parsed in the earlier\nfunction we saw, so that this loop can refrain from calling\nwildmatch() for non-wildcard elements?\n\n> +\t\tif (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {\n> +\t\t\tstring_list_append(cb->out, ref->name)->util =\n> +\t\t\t\txstrdup(upstream);\n> +\t\t\treturn 0;\n> +\t\t}\n> +\treturn 0;\n> +}\n> +\n> +static void collect_forked_set(int argc, const char **argv,\n> +\t\t\t       struct string_list *out)\n> +{\n> +\tstruct string_list upstream_patterns = STRING_LIST_INIT_DUP;\n> +\tstruct forked_cb cb = {\n> +\t\t.upstream_patterns = &upstream_patterns,\n> +\t\t.out = out,\n> +\t};\n> +\n> +\tparse_forked_args(argc, argv, &upstream_patterns);\n> +\n> +\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n> +\t\t\t\t collect_forked_branch, &cb);\n> +\n> +\tstring_list_clear(&upstream_patterns, 0);\n> +}\n> +\n> +static int list_forked_branches(int argc, const char **argv)\n> +{\n> +\tstruct string_list out = STRING_LIST_INIT_DUP;\n> +\tstruct string_list_item *item;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--forked requires at least one <branch>\"));\n> +\n> +\tcollect_forked_set(argc, argv, &out);\n> +\tfor_each_string_list_item(item, &out)\n> +\t\tputs(item->string);\n> +\n> +\tstring_list_clear(&out, 1);\n> +\treturn 0;\n> +}\n\nUp to point the changes are very much appropriate for adding the\n\"--forked\" option.\n\n> @@ -714,6 +822,7 @@ int cmd_branch(int argc,\n>  \t/* possible actions */\n>  \tint delete = 0, rename = 0, copy = 0, list = 0,\n>  \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n> +\tint forked = 0;\n>  \tconst char *new_upstream = NULL;\n>  \tint noncreate_actions = 0;\n>  \t/* possible options */\n> @@ -767,6 +876,8 @@ int cmd_branch(int argc,\n>  \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n>  \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n>  \t\t\t N_(\"edit the description for the branch\")),\n> +\t\tOPT_BOOL(0, \"forked\", &forked,\n> +\t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n>  \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>  \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>  \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -811,7 +922,7 @@ int cmd_branch(int argc,\n>  \t\t\t     0);\n>  \n>  \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n> -\t    !show_current && !unset_upstream && argc == 0)\n> +\t    !show_current && !unset_upstream && !forked && argc == 0)\n>  \t\tlist = 1;\n>  \n>  \tif (filter.with_commit || filter.no_commit ||\n> @@ -820,7 +931,7 @@ int cmd_branch(int argc,\n>  \n>  \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>  \t\t\t    !!show_current + !!list + !!edit_description +\n> -\t\t\t    !!unset_upstream;\n> +\t\t\t    !!unset_upstream + !!forked;\n>  \tif (noncreate_actions > 1)\n>  \t\tusage_with_options(builtin_branch_usage, options);\n>  \n> @@ -858,7 +969,11 @@ int cmd_branch(int argc,\n>  \tif (delete) {\n>  \t\tif (!argc)\n>  \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n> +\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n> +\t\t\t\t      quiet, 0, NULL);\n> +\t\tgoto out;\n\nThis does not belong to \"--forked\" as far as I can tell.\n\n> +\t} else if (forked) {\n> +\t\tret = list_forked_branches(argc, argv);\n>  \t\tgoto out;\n>  \t} else if (show_current) {\n>  \t\tprint_current_branch_name();\n"},{"id":"543874","messageId":"xmqq8q9cw40a.fsf@gitster.g","threadId":"65574","inReplyTo":"718e28c7e0120a826385189213cccec1f0fce1af.1779403204.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T02:51:33Z","receivedAt":"2026-05-22T02:51:36Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1e24c95a69..29d38e9060 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n\nDue to the way the patch is split between 1/4 and 2/4, it is\nimpossible to comment on the change to delete_branches etc. that are\nneeded for this step.  I'll use \"git diff master... builtin/\" instead.\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1572a4f9ef..b89fd56112 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -1,43 +1,47 @@\n> ...\n> @@ -132,78 +136,87 @@ static const char *branch_get_color(enum color_branch ix)\n>  static int branch_merged(int kind, const char *name,\n>  \t\t\t struct commit *rev, struct commit *head_rev)\n>  {\n\nThis one is called from check_branch_commit().  In \"--prune-merged\"\ncode paths, as we will see below, kind==FILTER_REFS_BRANCHES is\npassed.\n\nBut note that there is no reason to expect \"--prune-merged\" will be\nthe only one to pass FILTER_REFS_BRANCHES to this function.  Most\nnotably, \"git branch -d\" without \"-r\" would set FILTER_REFS_BRANCHES\nto filter.kind and passes the value here.\n\n>  \t/*\n>  \t * This checks whether the merge bases of branch and HEAD (or\n>  \t * the other branch this branch builds upon) contains the\n>  \t * branch, which means that the branch has already been merged\n>  \t * safely to HEAD (or the other branch).\n>  \t */\n>  \tstruct commit *reference_rev = NULL;\n>  \tconst char *reference_name = NULL;\n>  \tvoid *reference_name_to_free = NULL;\n>  \tint merged;\n>  \n>  \tif (kind == FILTER_REFS_BRANCHES) {\n>  \t\tstruct branch *branch = branch_get(name);\n>  \t\tconst char *upstream = branch_get_upstream(branch, NULL);\n>  \t\tstruct object_id oid;\n>  \n>  \t\tif (upstream &&\n>  \t\t    (reference_name = reference_name_to_free =\n>  \t\t     refs_resolve_refdup(get_main_ref_store(the_repository), upstream, RESOLVE_REF_READING,\n>  \t\t\t\t\t &oid, NULL)) != NULL)\n>  \t\t\treference_rev = lookup_commit_reference(the_repository,\n>  \t\t\t\t\t\t\t\t&oid);\n>  \t}\n\nWe find its upstream in reference_rev; if this is non-NULL, the\nbranch MUST be merged to that revision for it to be safely removed.\n\n>  \tif (!reference_rev)\n>  \t\treference_rev = head_rev;\n\nBut when ehad_rev is given, and if there is no upstream, we check if\nthe branch is merged to whatever happens to be checked out instead.\nFor the purpose of \"--prune-merged\", therefore, we MUST pass NULL in\nhead_rev when we call this function.  We'll see what is done in the\ncaller later.\n\n\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1e24c95a69..29d38e9060 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,\n>  \t * any of the following code, but during the transition period,\n>  \t * a gentle reminder is in order.\n>  \t */\n> -\tif (head_rev != reference_rev) {\n> -\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n> +\tif (head_rev && head_rev != reference_rev) {\n> +\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n\nAny caller who passed head_rev==NULL still used to come into this\nblock, set expect to 0, and have it compared with merged.  If merged\nis 0 (which is what happens when reference_rev is NULL and head_rev\nis NULL), the control left this block silently due to /* okay */\nbelow (which is in the post-context that is not shown).\n\nThe updated code refuses control to come into this block when\nhead_rev is NULL.  I am not sure why the change in this hunk is\nneeded.\n\n> @@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n>  \treturn 0;\n>  }\n>  \n> +static int collect_default_branch_name(struct remote *remote, void *cb_data)\n> +{\n> +\tstruct string_list *protected = cb_data;\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct strbuf head = STRBUF_INIT;\n> +\tconst char *target;\n> +\n> +\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n> +\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n> +\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n> +\tif (target) {\n> +\t\tconst char *leaf = strrchr(target, '/');\n> +\t\tif (leaf)\n> +\t\t\tstring_list_insert(protected, leaf + 1);\n> +\t}\n> +\tstrbuf_release(&head);\n> +\treturn 0;\n> +}\n\nIt is strange to assume that whatever upstream repository happens to\nuse as its primary branch name has anything to do with how the local\nrepository names its primary branch.  Shouldn't this instead use\ninit.defaultBranch configuration or something?\n\n> @@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)\n>  \treturn 0;\n>  }\n>  \n> +static int prune_merged_branches(int argc, const char **argv, int quiet)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n> +\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n> +\tstruct strvec deletable = STRVEC_INIT;\n> +\tstruct strbuf buf = STRBUF_INIT;\n> +\tstruct string_list_item *item;\n> +\tint n_not_merged = 0;\n> +\tint ret = 0;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n> +\n> +\tcollect_forked_set(argc, argv, &candidates);\n\nDue to poor separation of changes, all the necessary information to\nassess how sane the above code is is hidden in [1/4] and not here.\n\n> +\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n\nThis looks inefficient, and worse, incorrect.\n\nIf we have multiple branches in argv[], they may have come from\ndifferent upstream, and because you pay attention to what the\nrefs/remotes/<upstream>/HEAD symrefs point at, you have multiple\nsuch \"protected\" default names.  You'd compare each and every\ncandidates against these names using linear search in the\nstring_list to see if they are protected (inefficient), and you\nreject removal of argv[1] even when it happens to be the same name\nas the default in the repository from which the upstream of argv[3]\ncame from, i.e., has no relationship with argv[1] (incorrect).\n\n> +\tfor_each_string_list_item(item, &candidates) {\n> +\t\tconst char *short_name = item->string;\n> +\t\tconst char *upstream = item->util;\n> +\n> +\t\tstrbuf_reset(&buf);\n> +\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n> +\t\tif (branch_checked_out(buf.buf))\n> +\t\t\tcontinue;\n> +\n> +\t\tif (string_list_has_string(&protected_default_names,\n> +\t\t\t\t\t   short_name))\n> +\t\t\tcontinue;\n> +\n> +\t\tif (!refs_ref_exists(refs, upstream))\n> +\t\t\tcontinue;\n> +\n> +\t\tstrvec_push(&deletable, short_name);\n> +\t}\n> +\tstrbuf_release(&buf);\n> +\n> +\tif (deletable.nr)\n> +\t\tret = delete_branches(deletable.nr, deletable.v,\n> +\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n> +\t\t\t\t      1, &n_not_merged);\n\nAdd comments on parameters, perhaps, to make it readable?\n\n\t\tret = delete_branches(deletable.nr, deletable.v,\n\t\t\t\t      0, /* no force */\n\t\t\t\t      FILTER_REFS_BRANCHES,\n\t\t\t\t      quiet,\n\t\t\t\t      1, /* warn only */\n\t\t\t\t      &n_not_merged);\n\nor something?\n\nUnfortunately the body of delete_branches() updated by this series\nis not visible in this patch, making a sensible review impossible,\nbut if I recall correctly from what I saw in [1/4], with no-force\nset, it does not leave head_rev NULL, and instead reads the HEAD\ninto it, and that is eventually passed to check_branch_commit()\ncall.\n\nNow, check_branch_commit() passes head_rev to branch_merged(), which\nfalls back to \"HEAD\" when head_rev is given.  That sounds incorrect\nfor at least two reasons.  We are only dealing with branches that do\nhave upstream, so fallback should not trigger and we shouldn't be\nallowing head_rev to be passed.  It _might_ be debatable that it is\nprudent to still expect branch_merged() not to find the upstream to\ndetect errors in the logic, but falling back to head_rev in such a\ncase does not make any sense.\n\n> +\tif (n_not_merged && !quiet)\n> +\t\tfprintf(stderr,\n> +\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n> +\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   \"Skipped %d branches that are not fully merged; \"\n> +\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   n_not_merged),\n> +\t\t\tn_not_merged);\n\nI do not think we unconditionally want to see this, and \"--quiet\"\nshouldn't be the onlyl way to squelch this message.\n\nWhen !quiet, the warn_only call to check_branch_commit() would\nalready have reported which branches are not fully merged, and\nafter seeing this message a few times, even the most novice user\nwould know how to use \"git branch -D\" to remove unneeded branches.\n\nUse of advice_if_enabled() may make it more palatable.\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 45455cb8ce..c8589cd3a6 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1798,4 +1798,143 @@ test_expect_success '--forked requires at least one <branch>' '\n>  \ttest_grep \"at least one <branch>\" err\n>  '\n>  \n> +test_expect_success '--prune-merged: setup' '\n> +\ttest_create_repo pm-upstream &&\n> +\ttest_commit -C pm-upstream base &&\n> +\tgit -C pm-upstream checkout -b next &&\n> +\ttest_commit -C pm-upstream one-commit &&\n> +\ttest_commit -C pm-upstream two-commit &&\n> +\tgit -C pm-upstream branch one HEAD~ &&\n> +\tgit -C pm-upstream branch two HEAD &&\n> +\tgit -C pm-upstream branch wip main &&\n> +\tgit -C pm-upstream checkout main\n> +'\n> +\n> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> +\ttest_when_finished \"rm -rf pm-merged\" &&\n> +\tgit clone pm-upstream pm-merged &&\n> +\tgit -C pm-merged branch one one-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-merged branch two two-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n> +\n> +\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged with a literal upstream argument' '\n> +\ttest_when_finished \"rm -rf pm-literal\" &&\n> +\tgit clone pm-upstream pm-literal &&\n> +\tgit -C pm-literal branch one one-commit &&\n> +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-literal branch keepme one-commit &&\n> +\tgit -C pm-literal branch --set-upstream-to=origin/main keepme &&\n> +\n> +\tgit -C pm-literal branch --prune-merged origin/next &&\n> +\n> +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&\n> +\tgit -C pm-literal rev-parse --verify refs/heads/keepme\n> +'\n> +\n> +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-union\" &&\n> +\tgit clone pm-upstream pm-union &&\n> +\tgit -C pm-union branch one one-commit &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-union branch two base &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n> +\n> +\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n> +\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged with a local-branch argument' '\n> +\ttest_create_repo pm-local &&\n> +\ttest_when_finished \"rm -rf pm-local\" &&\n> +\ttest_commit -C pm-local base &&\n> +\tgit -C pm-local branch topic base &&\n> +\tgit -C pm-local config branch.topic.remote . &&\n> +\tgit -C pm-local config branch.topic.merge refs/heads/main &&\n> +\tgit -C pm-local checkout --detach &&\n> +\n> +\tgit -C pm-local branch --prune-merged main &&\n> +\n> +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&\n> +\tgit -C pm-local rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged spares branches with un-integrated commits' '\n> +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n> +\tgit clone pm-upstream pm-unmerged &&\n> +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n> +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-unmerged local-only &&\n> +\tgit -C pm-unmerged checkout - &&\n> +\n> +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n> +\ttest_grep \"not fully merged\" err &&\n> +\ttest_grep \"Skipped 1 branch\" err &&\n> +\ttest_grep \"git branch -D\" err &&\n> +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n> +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n> +'\n> +\n> +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n> +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n> +\tgit clone pm-upstream pm-upstream-gone &&\n> +\tgit -C pm-upstream-gone branch one one-commit &&\n> +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n> +\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged never deletes the checked-out branch' '\n> +\ttest_when_finished \"rm -rf pm-head\" &&\n> +\tgit clone pm-upstream pm-head &&\n> +\tgit -C pm-head checkout -b one one-commit &&\n> +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-head rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged spares the local default branch' '\n> +\ttest_when_finished \"rm -rf pm-default\" &&\n> +\tgit clone pm-upstream pm-default &&\n> +\tgit -C pm-default checkout --detach &&\n> +\tgit -C pm-default branch --prune-merged \"origin/*\" &&\n> +\tgit -C pm-default rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged protects the default branch by name only' '\n> +\ttest_when_finished \"rm -rf pm-default-alias\" &&\n> +\tgit clone pm-upstream pm-default-alias &&\n> +\tgit -C pm-default-alias branch --track trunk origin/main &&\n> +\tgit -C pm-default-alias checkout --detach &&\n> +\tgit -C pm-default-alias branch --prune-merged \"origin/*\" &&\n> +\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n> +\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n> +'\n> +\n> +test_expect_success '--prune-merged with literal arg also protects default-name' '\n> +\ttest_when_finished \"rm -rf pm-literal-default\" &&\n> +\tgit clone pm-upstream pm-literal-default &&\n> +\tgit -C pm-literal-default checkout --detach &&\n> +\tgit -C pm-literal-default branch --prune-merged origin/main &&\n> +\tgit -C pm-literal-default rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged requires at least one <branch>' '\n> +\ttest_must_fail git -C pm-upstream branch --prune-merged 2>err &&\n> +\ttest_grep \"at least one <branch>\" err\n> +'\n> +\n>  test_done\n"},{"id":"543875","messageId":"xmqq5x4gw3yu.fsf@gitster.g","threadId":"65574","inReplyTo":"718e28c7e0120a826385189213cccec1f0fce1af.1779403204.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T02:52:25Z","receivedAt":"2026-05-22T02:52:27Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1e24c95a69..29d38e9060 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -172,8 +174,8 @@ static int branch_merged(int kind, const char *name,\n>  \t * any of the following code, but during the transition period,\n>  \t * a gentle reminder is in order.\n>  \t */\n> -\tif (head_rev != reference_rev) {\n> -\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n> +\tif (head_rev && head_rev != reference_rev) {\n> +\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n\nAny caller who passed head_rev==NULL still used to come into this\nblock, set expect to 0, and have it compared with merged.  If merged\nis 0 (which is what happens when reference_rev is NULL and head_rev\nis NULL), the control left this block silently due to /* okay */\nbelow (which is in the post-context that is not shown).\n\nThe updated code refuses control to come into this block when\nhead_rev is NULL.  I am not sure why the change in this hunk is\nneeded.\n\n> @@ -748,6 +750,25 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n>  \treturn 0;\n>  }\n>  \n> +static int collect_default_branch_name(struct remote *remote, void *cb_data)\n> +{\n> +\tstruct string_list *protected = cb_data;\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct strbuf head = STRBUF_INIT;\n> +\tconst char *target;\n> +\n> +\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n> +\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n> +\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n> +\tif (target) {\n> +\t\tconst char *leaf = strrchr(target, '/');\n> +\t\tif (leaf)\n> +\t\t\tstring_list_insert(protected, leaf + 1);\n> +\t}\n> +\tstrbuf_release(&head);\n> +\treturn 0;\n> +}\n\nIt is strange to assume that whatever upstream repository happens to\nuse as its primary branch name has anything to do with how the local\nrepository names its primary branch.  Shouldn't this instead use\ninit.defaultBranch configuration or something?\n\n> @@ -781,6 +802,63 @@ static int list_forked_branches(int argc, const char **argv)\n>  \treturn 0;\n>  }\n>  \n> +static int prune_merged_branches(int argc, const char **argv, int quiet)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n> +\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n> +\tstruct strvec deletable = STRVEC_INIT;\n> +\tstruct strbuf buf = STRBUF_INIT;\n> +\tstruct string_list_item *item;\n> +\tint n_not_merged = 0;\n> +\tint ret = 0;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n> +\n> +\tcollect_forked_set(argc, argv, &candidates);\n\nDue to poor separation of changes, all the necessary information to\nassess how sane the above code is is hidden in [1/4] and not here.\n\n> +\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n\nThis looks inefficient, and worse, incorrect.\n\nIf we have multiple branches in argv[], they may have come from\ndifferent upstream, and because you pay attention to what the\nrefs/remotes/<upstream>/HEAD symrefs point at, you have multiple\nsuch \"protected\" default names.  You'd compare each and every\ncandidates against these names using linear search in the\nstring_list to see if they are protected (inefficient), and you\nreject removal of argv[1] even when it happens to be the same name\nas the default in the repository from which the upstream of argv[3]\ncame from, i.e., has no relationship with argv[1] (incorrect).\n\n> +\tfor_each_string_list_item(item, &candidates) {\n> +\t\tconst char *short_name = item->string;\n> +\t\tconst char *upstream = item->util;\n> +\n> +\t\tstrbuf_reset(&buf);\n> +\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n> +\t\tif (branch_checked_out(buf.buf))\n> +\t\t\tcontinue;\n> +\n> +\t\tif (string_list_has_string(&protected_default_names,\n> +\t\t\t\t\t   short_name))\n> +\t\t\tcontinue;\n> +\n> +\t\tif (!refs_ref_exists(refs, upstream))\n> +\t\t\tcontinue;\n> +\n> +\t\tstrvec_push(&deletable, short_name);\n> +\t}\n> +\tstrbuf_release(&buf);\n> +\n> +\tif (deletable.nr)\n> +\t\tret = delete_branches(deletable.nr, deletable.v,\n> +\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n> +\t\t\t\t      1, &n_not_merged);\n\nAdd comments on parameters, perhaps, to make it readable?\n\n\t\tret = delete_branches(deletable.nr, deletable.v,\n\t\t\t\t      0, /* no force */\n\t\t\t\t      FILTER_REFS_BRANCHES,\n\t\t\t\t      quiet,\n\t\t\t\t      1, /* warn only */\n\t\t\t\t      &n_not_merged);\n\nor something?\n\nUnfortunately the body of delete_branches() updated by this series\nis not visible in this patch, making a sensible review impossible,\nbut if I recall correctly from what I saw in [1/4], with no-force\nset, it does not leave head_rev NULL, and instead reads the HEAD\ninto it, and that is eventually passed to check_branch_commit()\ncall.\n\nNow, check_branch_commit() passes head_rev to branch_merged(), which\nfalls back to \"HEAD\" when head_rev is given.  That sounds incorrect\nfor at least two reasons.  We are only dealing with branches that do\nhave upstream, so fallback should not trigger and we shouldn't be\nallowing head_rev to be passed.  It _might_ be debatable that it is\nprudent to still expect branch_merged() not to find the upstream to\ndetect errors in the logic, but falling back to head_rev in such a\ncase does not make any sense.\n\n> +\tif (n_not_merged && !quiet)\n> +\t\tfprintf(stderr,\n> +\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n> +\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   \"Skipped %d branches that are not fully merged; \"\n> +\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n> +\t\t\t   n_not_merged),\n> +\t\t\tn_not_merged);\n\nI do not think we unconditionally want to see this, and \"--quiet\"\nshouldn't be the onlyl way to squelch this message.\n\nWhen !quiet, the warn_only call to check_branch_commit() would\nalready have reported which branches are not fully merged, and\nafter seeing this message a few times, even the most novice user\nwould know how to use \"git branch -D\" to remove unneeded branches.\n\nUse of advice_if_enabled() may make it more palatable.\n"},{"id":"543876","messageId":"xmqq1pf4w3x5.fsf@gitster.g","threadId":"65574","inReplyTo":"xmqq8q9cw40a.fsf@gitster.g","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T02:53:26Z","receivedAt":"2026-05-22T02:53:28Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n>\n>\n>\n>> diff --git a/builtin/branch.c b/builtin/branch.c\n>> index 1e24c95a69..29d38e9060 100644\n>> --- a/builtin/branch.c\n>> +++ b/builtin/branch.c\n>\n> Due to the way the patch is split between 1/4 and 2/4, it is\n> impossible to comment on the change to delete_branches etc. that are\n> needed for this step.  I'll use \"git diff master... builtin/\" instead.\n>\n\nPlease discard this version.  I had unnecessary draft comments that\nI used as reference in it.\n"},{"id":"543891","messageId":"273103d7-c816-4cde-9e89-b630c37b0749@kdbg.org","threadId":"65574","inReplyTo":"f2df15983067ce39b6c33ab81115863d5c3567f4.1779403204.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v10 1/4] branch: add --forked <branch>","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-22T06:18:50Z","receivedAt":"2026-05-22T06:19:00Z","isPatch":true,"body":"Am 22.05.26 um 00:40 schrieb Harald Nordgren via GitGitGadget:\n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index c0afddc424..3a421f6663 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n>  git branch (-c|-C) [<old-branch>] <new-branch>\n>  git branch (-d|-D) [-r] <branch-name>...\n>  git branch --edit-description [<branch-name>]\n> +git branch --forked <branch>...\n\nI would have preferred that this option is another filter of --list\nmode, not its own mode of operation. Consequently, each --forked option\nwould take only a single argument (which can contain globs), and can be\ngiven multiple times.\n\n>  \n>  DESCRIPTION\n>  -----------\n> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.\n>  \tPrint the name of the current branch. In detached `HEAD` state,\n>  \tnothing is printed.\n>  \n> +`--forked`::\n> +\tList local branches whose configured upstream matches any\n> +\tof the given _<branch>_ arguments. Each argument is either\n> +\ta ref (e.g. `origin/master`, `master`) or a shell-style\n> +\tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n\nSo this could perhaps read:\n\n`--forked`::\n\tList only branches whose configured upstream matches\n\t_<branch>_. The argument can contain a shell-style glob\n\t (e.g. `'origin/*'`). The option can be repeated to\n\twiden the filter.\n\nNote that there is no reason to say \"local branches\". (\"... are unioned\"\nsounds strange, so this is may attempt to express the same in a\ndifferent way.)\n\nThe icing on the cake would now be that\n\n    git branch --merged origin/main --forked origin/*\n\nprovides the list of branches forked from origin that have already been\nintegrated.\n\n-- Hannes\n\n"},{"id":"543893","messageId":"xmqqse7kt0ge.fsf@gitster.g","threadId":"65574","inReplyTo":"273103d7-c816-4cde-9e89-b630c37b0749@kdbg.org","subject":"Re: [PATCH v10 1/4] branch: add --forked <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T06:36:33Z","receivedAt":"2026-05-22T06:36:37Z","isPatch":true,"body":"Johannes Sixt <j6t@kdbg.org> writes:\n\n> The icing on the cake would now be that\n>\n>     git branch --merged origin/main --forked origin/*\n>\n> provides the list of branches forked from origin that have already been\n> integrated.\n\nYup, that is very nice.  Also with \"--merged\" replaced with\n\"--not-merged\", i.e., \"our work building on top of origin's, and\nstill need to be finished\", would give us a good list to work on.\n"},{"id":"543897","messageId":"CAHwyqnXVnFOeKRXQHHZMLbj_WL55A5gPxof=VFhZxDeXTT8kqw@mail.gmail.com","threadId":"65574","inReplyTo":"xmqq1pf4w3x5.fsf@gitster.g","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-22T07:59:26Z","receivedAt":"2026-05-22T08:00:04Z","isPatch":true,"body":"> Please discard this version.  I had unnecessary draft comments that\n> I used as reference in it.\n\nI'm taking this to mean starting over from v9 and implementing the\n'origin/*' idea again from there. Correct?\n\n\nHarald\n"},{"id":"543905","messageId":"6ae90274-3fbb-4d2a-b0f4-cd9260e4d6b3@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnVhhwT80Ao+7QLUAsTnUJaN5vE=ZiaxeqF3rYxxiD_Qww@mail.gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-05-22T09:47:43Z","receivedAt":"2026-05-22T09:47:46Z","isPatch":true,"body":"Hi Harald\n\nOn 21/05/2026 20:16, Harald Nordgren wrote:\n>> While we want to clean up topic branches, we want to avoid cleaning up\n>> branches like \"master\" which follow an upstream branch and therefore\n>> look like they've been merged straight after they've been pulled. So I\n>> think as well as checking that the local branch is merged into its\n>> upstream branch, we want to check that the local branch is not pushed to\n>> the upstream branch i.e. that branch@{upstream} != branch@{push}.\n> \n> This one I handle already by letting the default branch be guarded.\n\nI used \"master\" as an example of a branch name above. There is no \nguarantee that a remote even defines a default branch, let alone that \nthere is only one local branch where the its upstream and push \ndestinations match. I don't see how you can avoid checking that the \nbranch pushes to a different ref than its upstream and still be safe.\n\nI'm going to be off the list from now until the week after next, I'll \ncatch up with this thread when I'm back on line.\n\nThanks\n\nPhillip\n\n\n> \n> Harald\n> \n> On Thu, May 21, 2026 at 11:46 AM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>>\n>> Hi Harald\n>>\n>> A couple more thoughts ...\n>>\n>> On 18/05/2026 16:27, Phillip Wood wrote:\n>>> On 13/05/2026 20:34, Harald Nordgren via GitGitGadget wrote:\n>>>> From: Harald Nordgren <haraldnordgren@gmail.com>\n>>>>\n>>>> Delete the local branches that --forked <remote> would list, but\n>>>> only those whose tip is reachable from their configured upstream\n>>>> remote-tracking branch (branch.<name>.merge): the work has already\n>>>> landed on the upstream it tracks, so the local copy is no longer\n>>>> needed.\n>>\n>> While we want to clean up topic branches, we want to avoid cleaning up\n>> branches like \"master\" which follow an upstream branch and therefore\n>> look like they've been merged straight after they've been pulled. So I\n>> think as well as checking that the local branch is merged into its\n>> upstream branch, we want to check that the local branch is not pushed to\n>> the upstream branch i.e. that branch@{upstream} != branch@{push}. That\n>> should also avoid deleting newly created topic branches that match their\n>> upstream (I think that's probably less likely to happen in practice as\n>> I'd expect the branch to be checked out and therefore protected against\n>> deletion).\n>>\n>> Also as this is a destructive operation (there is no way to restore a\n>> deleted branch and its reflog) it would be good to have a --dry-run option.\n>>\n>> Thanks\n>>\n>> Phillip\n>>\n> \n\n"},{"id":"543906","messageId":"CAHwyqnX=zvjpy3w8qn+H7L_Ncxs5+tK5Va-Lr4ZXX=XYLs2YZQ@mail.gmail.com","threadId":"65574","inReplyTo":"xmqqse7kt0ge.fsf@gitster.g","subject":"Re: [PATCH v10 1/4] branch: add --forked <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-22T10:49:46Z","receivedAt":"2026-05-22T10:50:26Z","isPatch":true,"body":"> Johannes Sixt <j6t@kdbg.org> writes:\n>\n> > The icing on the cake would now be that\n> >\n> >     git branch --merged origin/main --forked origin/*\n> >\n> > provides the list of branches forked from origin that have already been\n> > integrated.\n>\n> Yup, that is very nice.  Also with \"--merged\" replaced with\n> \"--not-merged\", i.e., \"our work building on top of origin's, and\n> still need to be finished\", would give us a good list to work on.\n\nThis is nice, but I think this would require an overhaul of other\ninfra as well, maybe better to do as a follow-up?\n\n\nHarald\n"},{"id":"543907","messageId":"CAHwyqnXSq-fSE3vhtaOxQ9iy1dwP-Rg3Db5dD75Pn8N5dfJqsg@mail.gmail.com","threadId":"65574","inReplyTo":"6ae90274-3fbb-4d2a-b0f4-cd9260e4d6b3@gmail.com","subject":"Re: [PATCH v9 3/5] branch: add --prune-merged <remote>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-05-22T10:51:03Z","receivedAt":"2026-05-22T10:51:41Z","isPatch":true,"body":"> >> While we want to clean up topic branches, we want to avoid cleaning up\n> >> branches like \"master\" which follow an upstream branch and therefore\n> >> look like they've been merged straight after they've been pulled. So I\n> >> think as well as checking that the local branch is merged into its\n> >> upstream branch, we want to check that the local branch is not pushed to\n> >> the upstream branch i.e. that branch@{upstream} != branch@{push}.\n> >\n> > This one I handle already by letting the default branch be guarded.\n>\n> I used \"master\" as an example of a branch name above. There is no\n> guarantee that a remote even defines a default branch, let alone that\n> there is only one local branch where the its upstream and push\n> destinations match. I don't see how you can avoid checking that the\n> branch pushes to a different ref than its upstream and still be safe.\n\nOk, I'll give it a shot!\n\n> I'm going to be off the list from now until the week after next, I'll\n> catch up with this thread when I'm back on line.\n\nEnjoy the time off!\n\n\nHarald\n"},{"id":"543908","messageId":"190c909d-4c17-488f-9ebe-f7927ebf7e87@kdbg.org","threadId":"65574","inReplyTo":"CAHwyqnX=zvjpy3w8qn+H7L_Ncxs5+tK5Va-Lr4ZXX=XYLs2YZQ@mail.gmail.com","subject":"Re: [PATCH v10 1/4] branch: add --forked <branch>","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-22T11:25:18Z","receivedAt":"2026-05-22T11:25:22Z","isPatch":true,"body":"Am 22.05.26 um 12:49 schrieb Harald Nordgren:\n>> Johannes Sixt <j6t@kdbg.org> writes:\n>>\n>>> The icing on the cake would now be that\n>>>\n>>>     git branch --merged origin/main --forked origin/*\n>>>\n>>> provides the list of branches forked from origin that have already been\n>>> integrated.\n>>\n>> Yup, that is very nice.  Also with \"--merged\" replaced with\n>> \"--not-merged\", i.e., \"our work building on top of origin's, and\n>> still need to be finished\", would give us a good list to work on.\n> \n> This is nice, but I think this would require an overhaul of other\n> infra as well, maybe better to do as a follow-up?\nThis can certainly be done as an extension in a follow-up patch. But the\nUI must still be planned accordingly, i.e., --forked can only take a\nsingle argument. For example, in\n\n    git branch --forked foo bar\n\n'bar' is the pattern of branches to show. The \"list\" is filtered\naccording to '--forked foo'. That is, if 'bar' was not forked from\n'foo', the output is empty.\n\nYou would have to require\n\n    git branch --forked foo --forked bar\n\nto list all branches forked from 'foo' or 'bar'.\n\nIn the first implementation, you can restrict uses of other options with\n--forked or even with a branch pattern. But you cannot be loose by\naccepting multiple branch patterns with one --forked option, because\nthat would later clash with --list mode.\n\n-- Hannes\n\n"},{"id":"543910","messageId":"b9fddd124afc55ad05bd4c85b060cad5e4833cc8.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 1/6] branch: add --forked <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:33Z","receivedAt":"2026-05-22T11:31:45Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nList local branches whose configured upstream\n(branch.<name>.merge resolved against branch.<name>.remote)\nmatches any of the given <branch> arguments.\n\nEach <branch> is interpreted against the local repository, not\nagainst any specific remote:\n\n  * a literal upstream short name, e.g. \"origin/main\" or \"master\"\n    for a branch whose upstream is local;\n  * a wildmatch pattern, e.g. \"origin/*\";\n  * a bare configured-remote name, e.g. \"origin\", which resolves\n    to whatever refs/remotes/origin/HEAD points at, matching how\n    \"git checkout -b topic origin\" picks a starting point.\n\nThe literal-vs-wildcard distinction is settled at parse time so\nthe per-branch matching loop calls wildmatch() only for genuine\nwildcards. Multiple <branch> arguments are unioned. Output is\nsorted by branch name.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  13 +++\n builtin/branch.c              | 168 +++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             |  90 ++++++++++++++++++\n 3 files changed, 269 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..a37d3a12cb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --forked <branch>...\n \n DESCRIPTION\n -----------\n@@ -199,6 +200,18 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked`::\n+\tList local branches whose configured upstream\n+\t(`branch.<name>.merge` resolved against `branch.<name>.remote`)\n+\tmatches any of the given _<branch>_ arguments.\n++\n+Each _<branch>_ is interpreted against the local repository: a literal\n+upstream like `origin/main` or a local branch like `master`, or a\n+wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,\n+to match the way `git checkout -b topic origin` picks a starting\n+point.  Multiple _<branch>_ arguments are unioned.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..2d34ad34dc 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -28,6 +28,7 @@\n #include \"help.h\"\n #include \"advice.h\"\n #include \"commit-reach.h\"\n+#include \"wildmatch.h\"\n \n static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --forked <branch>...\"),\n \tNULL\n };\n \n@@ -673,6 +675,162 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+struct upstream_pattern {\n+\tchar *name;\n+\tint is_wildcard;\n+};\n+\n+static void upstream_pattern_list_clear(struct upstream_pattern *items,\n+\t\t\t\t\tsize_t nr)\n+{\n+\tsize_t i;\n+\tfor (i = 0; i < nr; i++)\n+\t\tfree(items[i].name);\n+\tfree(items);\n+}\n+\n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct remote *remote;\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\tstruct strbuf head_ref = STRBUF_INIT;\n+\tconst char *resolved;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tout->name = xstrdup(arg);\n+\t\tout->is_wildcard = 1;\n+\t\treturn 0;\n+\t}\n+\n+\tremote = remote_get(arg);\n+\tif (remote && remote_is_configured(remote, 0)) {\n+\t\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote->name);\n+\t\tresolved = refs_resolve_ref_unsafe(refs, head_ref.buf,\n+\t\t\t\t\t\t   RESOLVE_REF_NO_RECURSE,\n+\t\t\t\t\t\t   NULL, NULL);\n+\t\tif (resolved && starts_with(resolved, \"refs/remotes/\")) {\n+\t\t\tout->name = xstrdup(short_upstream_name(resolved));\n+\t\t\tout->is_wildcard = 0;\n+\t\t\tstrbuf_release(&head_ref);\n+\t\t\treturn 0;\n+\t\t}\n+\t\tstrbuf_release(&head_ref);\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tout->name = xstrdup(short_upstream_name(full_ref));\n+\t\tout->is_wildcard = 0;\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n+static void parse_forked_args(int argc, const char **argv,\n+\t\t\t      struct upstream_pattern **patterns_out,\n+\t\t\t      size_t *nr_out)\n+{\n+\tstruct upstream_pattern *patterns;\n+\tint i;\n+\n+\tALLOC_ARRAY(patterns, argc);\n+\tfor (i = 0; i < argc; i++) {\n+\t\tif (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {\n+\t\t\tupstream_pattern_list_clear(patterns, i);\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    argv[i]);\n+\t\t}\n+\t}\n+\t*patterns_out = patterns;\n+\t*nr_out = argc;\n+}\n+\n+static int upstream_matches(const char *short_upstream,\n+\t\t\t    const struct upstream_pattern *patterns,\n+\t\t\t    size_t nr)\n+{\n+\tsize_t i;\n+\n+\tfor (i = 0; i < nr; i++) {\n+\t\tconst struct upstream_pattern *p = &patterns[i];\n+\t\tif (p->is_wildcard) {\n+\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(p->name, short_upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+struct forked_cb {\n+\tconst struct upstream_pattern *patterns;\n+\tsize_t nr_patterns;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tbranch = branch_get(ref->name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\tif (upstream_matches(short_upstream_name(upstream),\n+\t\t\t     cb->patterns, cb->nr_patterns))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct upstream_pattern *patterns = NULL;\n+\tsize_t nr_patterns = 0;\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tstruct forked_cb cb;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <branch>\"));\n+\n+\tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n+\tcb.patterns = patterns;\n+\tcb.nr_patterns = nr_patterns;\n+\tcb.out = &out;\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(&out);\n+\tfor_each_string_list_item(item, &out)\n+\t\tputs(item->string);\n+\n+\tupstream_pattern_list_clear(patterns, nr_patterns);\n+\tstring_list_clear(&out, 0);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +872,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint forked = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +926,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"forked\", &forked,\n+\t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -811,7 +972,7 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -820,7 +981,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!forked;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -860,6 +1021,9 @@ int cmd_branch(int argc,\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n \t\tgoto out;\n+\t} else if (forked) {\n+\t\tret = list_forked_branches(argc, argv);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..013ddfb65d 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,94 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch local-base &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached &&\n+\tgit -C forked branch --track local-trunk local-base\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '\n+\tgit -C forked branch --forked origin/one >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> matches by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base >actual &&\n+\techo local-trunk >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '\n+\ttest_when_finished \"git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main\" &&\n+\tgit -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&\n+\tgit -C forked branch --forked origin >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked unions multiple <branch> arguments' '\n+\tgit -C forked branch --forked origin/one other/foreign >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base \"other/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-trunk\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"at least one <branch>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543913","messageId":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com","subject":"[PATCH v11 0/6] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:32Z","receivedAt":"2026-05-22T11:31:45Z","isPatch":true,"body":"After releasing v10, I hard-reset back to v9 and reworked the series from\nthere.\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/*. The old --all-remotes flag is gone, since origin/*\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier <remote>/HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nHarald Nordgren (6):\n  branch: add --forked <branch>\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --prune-merged <branch>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --dry-run for --prune-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  42 ++++\n builtin/branch.c                 | 303 +++++++++++++++++++++++++--\n t/t3200-branch.sh                | 347 +++++++++++++++++++++++++++++++\n 4 files changed, 682 insertions(+), 17 deletions(-)\n\n\nbase-commit: aec3f587505a472db67e9462d0702e7d463a449d\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v11\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v11\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v10:\n\n 1:  f2df159830 ! 1:  b9fddd124a branch: add --forked <branch>\n     @@ Metadata\n       ## Commit message ##\n          branch: add --forked <branch>\n      \n     -            git branch --forked <branch>...\n     +    List local branches whose configured upstream\n     +    (branch.<name>.merge resolved against branch.<name>.remote)\n     +    matches any of the given <branch> arguments.\n      \n     -    lists local branches whose configured upstream matches any\n     -    of the given <branch> arguments.\n     +    Each <branch> is interpreted against the local repository, not\n     +    against any specific remote:\n      \n     -    Each <branch> is resolved to the same kind of ref that\n     -    branch.<name>.remote and branch.<name>.merge together point at:\n     -    a remote-tracking branch (e.g. origin/master), or, for branches\n     -    tracking a local upstream, a local branch (e.g. master).\n     -    Shell-style globs are also accepted (e.g. 'origin/*'). Multiple\n     -    arguments are unioned.\n     +      * a literal upstream short name, e.g. \"origin/main\" or \"master\"\n     +        for a branch whose upstream is local;\n     +      * a wildmatch pattern, e.g. \"origin/*\";\n     +      * a bare configured-remote name, e.g. \"origin\", which resolves\n     +        to whatever refs/remotes/origin/HEAD points at, matching how\n     +        \"git checkout -b topic origin\" picks a starting point.\n      \n     -    This is the building block for --prune-merged.\n     +    The literal-vs-wildcard distinction is settled at parse time so\n     +    the per-branch matching loop calls wildmatch() only for genuine\n     +    wildcards. Multiple <branch> arguments are unioned. Output is\n     +    sorted by branch name.\n     +\n     +    This is the building block for --prune-merged, which deletes the\n     +    listed branches once they have landed on their upstream.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n       \tnothing is printed.\n       \n      +`--forked`::\n     -+\tList local branches whose configured upstream matches any\n     -+\tof the given _<branch>_ arguments. Each argument is either\n     -+\ta ref (e.g. `origin/master`, `master`) or a shell-style\n     -+\tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n     ++\tList local branches whose configured upstream\n     ++\t(`branch.<name>.merge` resolved against `branch.<name>.remote`)\n     ++\tmatches any of the given _<branch>_ arguments.\n     +++\n     ++Each _<branch>_ is interpreted against the local repository: a literal\n     ++upstream like `origin/main` or a local branch like `master`, or a\n     ++wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n     ++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,\n     ++to match the way `git checkout -b topic origin` picks a starting\n     ++point.  Multiple _<branch>_ arguments are unioned.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tNULL\n       };\n       \n     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     - \n     - static int check_branch_commit(const char *branchname, const char *refname,\n     - \t\t\t       const struct object_id *oid, struct commit *head_rev,\n     --\t\t\t       int kinds, int force)\n     -+\t\t\t       int kinds, int force, int warn_only,\n     -+\t\t\t       int *n_not_merged)\n     - {\n     - \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     - \tif (!force && !rev) {\n     -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n     - \t\treturn -1;\n     - \t}\n     - \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     --\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n     --\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     --\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     --\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\tif (warn_only) {\n     -+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n     -+\t\t\t\tbranchname);\n     -+\t\t} else {\n     -+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n     -+\t\t\t      branchname);\n     -+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     -+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     -+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\t}\n     -+\t\tif (n_not_merged)\n     -+\t\t\t(*n_not_merged)++;\n     - \t\treturn -1;\n     - \t}\n     - \treturn 0;\n     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     - }\n     - \n     - static int delete_branches(int argc, const char **argv, int force, int kinds,\n     --\t\t\t   int quiet)\n     -+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n     - {\n     - \tstruct commit *head_rev = NULL;\n     - \tstruct object_id oid;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     - \n     - \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     - \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n     --\t\t\t\t\tforce)) {\n     --\t\t\tret = 1;\n     -+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n     -+\t\t\tif (!warn_only)\n     -+\t\t\t\tret = 1;\n     - \t\t\tgoto next;\n     - \t\t}\n     - \n      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n       \tfree_worktrees(worktrees);\n       }\n       \n     ++struct upstream_pattern {\n     ++\tchar *name;\n     ++\tint is_wildcard;\n     ++};\n     ++\n     ++static void upstream_pattern_list_clear(struct upstream_pattern *items,\n     ++\t\t\t\t\tsize_t nr)\n     ++{\n     ++\tsize_t i;\n     ++\tfor (i = 0; i < nr; i++)\n     ++\t\tfree(items[i].name);\n     ++\tfree(items);\n     ++}\n     ++\n     ++static const char *short_upstream_name(const char *full_ref)\n     ++{\n     ++\tconst char *short_name = full_ref;\n     ++\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n     ++\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n     ++\treturn short_name;\n     ++}\n     ++\n     ++static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n     ++{\n     ++\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     ++\tstruct remote *remote;\n     ++\tstruct object_id oid;\n     ++\tchar *full_ref = NULL;\n     ++\tstruct strbuf head_ref = STRBUF_INIT;\n     ++\tconst char *resolved;\n     ++\n     ++\tif (has_glob_specials(arg)) {\n     ++\t\tout->name = xstrdup(arg);\n     ++\t\tout->is_wildcard = 1;\n     ++\t\treturn 0;\n     ++\t}\n     ++\n     ++\tremote = remote_get(arg);\n     ++\tif (remote && remote_is_configured(remote, 0)) {\n     ++\t\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote->name);\n     ++\t\tresolved = refs_resolve_ref_unsafe(refs, head_ref.buf,\n     ++\t\t\t\t\t\t   RESOLVE_REF_NO_RECURSE,\n     ++\t\t\t\t\t\t   NULL, NULL);\n     ++\t\tif (resolved && starts_with(resolved, \"refs/remotes/\")) {\n     ++\t\t\tout->name = xstrdup(short_upstream_name(resolved));\n     ++\t\t\tout->is_wildcard = 0;\n     ++\t\t\tstrbuf_release(&head_ref);\n     ++\t\t\treturn 0;\n     ++\t\t}\n     ++\t\tstrbuf_release(&head_ref);\n     ++\t}\n     ++\n     ++\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n     ++\t\t\t  &full_ref, 0) == 1 &&\n     ++\t    (starts_with(full_ref, \"refs/heads/\") ||\n     ++\t     starts_with(full_ref, \"refs/remotes/\"))) {\n     ++\t\tout->name = xstrdup(short_upstream_name(full_ref));\n     ++\t\tout->is_wildcard = 0;\n     ++\t\tfree(full_ref);\n     ++\t\treturn 0;\n     ++\t}\n     ++\tfree(full_ref);\n     ++\treturn -1;\n     ++}\n     ++\n      +static void parse_forked_args(int argc, const char **argv,\n     -+\t\t\t      struct string_list *upstream_patterns)\n     ++\t\t\t      struct upstream_pattern **patterns_out,\n     ++\t\t\t      size_t *nr_out)\n      +{\n     ++\tstruct upstream_pattern *patterns;\n      +\tint i;\n      +\n     ++\tALLOC_ARRAY(patterns, argc);\n      +\tfor (i = 0; i < argc; i++) {\n     -+\t\tconst char *arg = argv[i];\n     -+\t\tstruct object_id oid;\n     -+\t\tchar *full_ref = NULL;\n     -+\t\tconst char *short_ref;\n     -+\n     -+\t\tif (has_glob_specials(arg)) {\n     -+\t\t\tstring_list_insert(upstream_patterns, arg);\n     -+\t\t\tcontinue;\n     ++\t\tif (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {\n     ++\t\t\tupstream_pattern_list_clear(patterns, i);\n     ++\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n     ++\t\t\t    argv[i]);\n      +\t\t}\n     ++\t}\n     ++\t*patterns_out = patterns;\n     ++\t*nr_out = argc;\n     ++}\n      +\n     -+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n     -+\t\t\t\t  &full_ref, 0) == 1 &&\n     -+\t\t    (skip_prefix(full_ref, \"refs/heads/\", &short_ref) ||\n     -+\t\t     skip_prefix(full_ref, \"refs/remotes/\", &short_ref))) {\n     -+\t\t\tstring_list_insert(upstream_patterns, short_ref);\n     -+\t\t\tfree(full_ref);\n     -+\t\t\tcontinue;\n     -+\t\t}\n     -+\t\tfree(full_ref);\n     ++static int upstream_matches(const char *short_upstream,\n     ++\t\t\t    const struct upstream_pattern *patterns,\n     ++\t\t\t    size_t nr)\n     ++{\n     ++\tsize_t i;\n      +\n     -+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n     ++\tfor (i = 0; i < nr; i++) {\n     ++\t\tconst struct upstream_pattern *p = &patterns[i];\n     ++\t\tif (p->is_wildcard) {\n     ++\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n     ++\t\t\t\treturn 1;\n     ++\t\t} else if (!strcmp(p->name, short_upstream)) {\n     ++\t\t\treturn 1;\n     ++\t\t}\n      +\t}\n     ++\treturn 0;\n      +}\n      +\n      +struct forked_cb {\n     -+\tconst struct string_list *upstream_patterns;\n     ++\tconst struct upstream_pattern *patterns;\n     ++\tsize_t nr_patterns;\n      +\tstruct string_list *out;\n      +};\n      +\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +{\n      +\tstruct forked_cb *cb = cb_data;\n      +\tstruct branch *branch;\n     -+\tconst char *upstream, *short_upstream;\n     -+\tconst struct string_list_item *item;\n     ++\tconst char *upstream;\n      +\n      +\tif (ref->flags & REF_ISSYMREF)\n      +\t\treturn 0;\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\tupstream = branch_get_upstream(branch, NULL);\n      +\tif (!upstream)\n      +\t\treturn 0;\n     -+\tshort_upstream = upstream;\n     -+\t(void)(skip_prefix(short_upstream, \"refs/heads/\", &short_upstream) ||\n     -+\t       skip_prefix(short_upstream, \"refs/remotes/\", &short_upstream));\n     -+\n     -+\tfor_each_string_list_item(item, cb->upstream_patterns)\n     -+\t\tif (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {\n     -+\t\t\tstring_list_append(cb->out, ref->name)->util =\n     -+\t\t\t\txstrdup(upstream);\n     -+\t\t\treturn 0;\n     -+\t\t}\n     ++\tif (upstream_matches(short_upstream_name(upstream),\n     ++\t\t\t     cb->patterns, cb->nr_patterns))\n     ++\t\tstring_list_append(cb->out, ref->name);\n      +\treturn 0;\n      +}\n      +\n     -+static void collect_forked_set(int argc, const char **argv,\n     -+\t\t\t       struct string_list *out)\n     -+{\n     -+\tstruct string_list upstream_patterns = STRING_LIST_INIT_DUP;\n     -+\tstruct forked_cb cb = {\n     -+\t\t.upstream_patterns = &upstream_patterns,\n     -+\t\t.out = out,\n     -+\t};\n     -+\n     -+\tparse_forked_args(argc, argv, &upstream_patterns);\n     -+\n     -+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     -+\t\t\t\t collect_forked_branch, &cb);\n     -+\n     -+\tstring_list_clear(&upstream_patterns, 0);\n     -+}\n     -+\n      +static int list_forked_branches(int argc, const char **argv)\n      +{\n     ++\tstruct upstream_pattern *patterns = NULL;\n     ++\tsize_t nr_patterns = 0;\n      +\tstruct string_list out = STRING_LIST_INIT_DUP;\n      +\tstruct string_list_item *item;\n     ++\tstruct forked_cb cb;\n      +\n      +\tif (!argc)\n      +\t\tdie(_(\"--forked requires at least one <branch>\"));\n      +\n     -+\tcollect_forked_set(argc, argv, &out);\n     ++\tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n     ++\tcb.patterns = patterns;\n     ++\tcb.nr_patterns = nr_patterns;\n     ++\tcb.out = &out;\n     ++\n     ++\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     ++\t\t\t\t collect_forked_branch, &cb);\n     ++\n     ++\tstring_list_sort(&out);\n      +\tfor_each_string_list_item(item, &out)\n      +\t\tputs(item->string);\n      +\n     -+\tstring_list_clear(&out, 1);\n     ++\tupstream_pattern_list_clear(patterns, nr_patterns);\n     ++\tstring_list_clear(&out, 0);\n      +\treturn 0;\n      +}\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \tif (delete) {\n     - \t\tif (!argc)\n       \t\t\tdie(_(\"branch name required\"));\n     --\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     -+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     -+\t\t\t\t      quiet, 0, NULL);\n     -+\t\tgoto out;\n     + \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     + \t\tgoto out;\n      +\t} else if (forked) {\n      +\t\tret = list_forked_branches(argc, argv);\n     - \t\tgoto out;\n     ++\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     + \t\tret = 0;\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tgit clone forked-upstream forked &&\n      +\tgit -C forked remote add other ../forked-other &&\n      +\tgit -C forked fetch other &&\n     ++\tgit -C forked branch local-base &&\n      +\tgit -C forked branch --track local-one origin/one &&\n      +\tgit -C forked branch --track local-two origin/two &&\n      +\tgit -C forked branch --track local-foreign other/foreign &&\n      +\tgit -C forked branch detached &&\n     -+\tgit -C forked branch --track topic-on-main main\n     ++\tgit -C forked branch --track local-trunk local-base\n      +'\n      +\n     -+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '\n     ++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '\n      +\tgit -C forked branch --forked origin/one >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '\n     -+\tgit -C forked branch --forked main >actual &&\n     -+\techo topic-on-main >expect &&\n     -+\ttest_cmp expect actual\n     -+'\n     -+\n     -+test_expect_success '--forked <glob> matches every upstream under the pattern' '\n     ++test_expect_success '--forked <glob> matches by wildmatch' '\n      +\tgit -C forked branch --forked \"origin/*\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\ttest_cmp expect actual\n      +'\n      +\n     ++test_expect_success '--forked <local-branch> matches branches with local upstream' '\n     ++\tgit -C forked branch --forked local-base >actual &&\n     ++\techo local-trunk >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n     ++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '\n     ++\ttest_when_finished \"git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main\" &&\n     ++\tgit -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&\n     ++\tgit -C forked branch --forked origin >actual &&\n     ++\techo local-one >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n      +test_expect_success '--forked unions multiple <branch> arguments' '\n      +\tgit -C forked branch --forked origin/one other/foreign >actual &&\n      +\tcat >expect <<-\\EOF &&\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked combines literal and glob arguments' '\n     -+\tgit -C forked branch --forked main \"other/*\" >actual &&\n     ++\tgit -C forked branch --forked local-base \"other/*\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n     -+\ttopic-on-main\n     ++\tlocal-trunk\n      +\tEOF\n      +\ttest_cmp expect actual\n      +'\n -:  ---------- > 2:  b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal\n -:  ---------- > 3:  6e6580270e branch: prepare delete_branches for a bulk caller\n 2:  718e28c7e0 ! 4:  e7e03c1338 branch: add --prune-merged <branch>\n     @@ Commit message\n      \n                  git branch --prune-merged <branch>...\n      \n     -    deletes the local branches that --forked <branch> would list,\n     -    but only those whose tip is reachable from their configured\n     -    upstream: the work has already landed on the upstream the\n     -    branch tracks, so the local copy is no longer needed.\n     +    deletes the local branches that \"--forked <branch>\" would list,\n     +    restricted to those whose tip is reachable from their configured\n     +    upstream: the work has already landed on the upstream they track,\n     +    so the local copy is no longer needed.\n      \n     -    The following branches are always preserved:\n     +    Reachability is read from the local refs only -- nothing is\n     +    fetched. Users who want fresh upstream refs run \"git fetch\" first;\n     +    the deletion path stays a separate, idempotent step that also\n     +    works offline.\n      \n     -    * the currently checked-out branch in any worktree;\n     -    * any local branch whose name matches the default branch of\n     -      any configured remote (the target of\n     -      refs/remotes/<remote>/HEAD) -- typically 'main' or\n     -      'master';\n     -    * any branch whose upstream no longer resolves locally.\n     +    Three classes of branches are spared:\n      \n     -    Reachability is read from whatever branch.<name>.merge\n     -    resolves to locally, which is usually a remote-tracking ref\n     -    but may also be a local branch. When the upstream is a\n     -    remote-tracking ref, the natural workflow is\n     +      * any branch checked out in any worktree;\n     +      * any branch whose upstream no longer resolves locally (its\n     +        disappearance is not, on its own, evidence of integration);\n     +      * any branch whose push destination equals its upstream\n     +        (<branch>@{push} == <branch>@{upstream}). Such a branch\n     +        cannot be distinguished from a freshly pulled trunk that\n     +        just looks \"fully merged\" -- e.g. local \"main\" tracking and\n     +        pushing to \"origin/main\" right after a pull. Only branches\n     +        that push somewhere other than their upstream (typically\n     +        topics in a fork-based workflow) are treated as candidates.\n      \n     -            git fetch <remote>\n     -            git branch --prune-merged <upstream-pattern>\n     -\n     -    so the upstream reflects the current state before pruning.\n     +    Deletion goes through the existing delete_branches() in warn-only\n     +    mode and with the HEAD-fallback disabled: a branch that is not\n     +    yet fully merged to its upstream is reported as a one-line warning\n     +    and skipped, so a single un-mergeable topic does not abort the\n     +    whole sweep, and there is no fallback to \"merged into the\n     +    currently checked out branch\" -- we only act on upstream-merged\n     +    status.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n     - \ta ref (e.g. `origin/master`, `master`) or a shell-style\n     - \tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n     +@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n     + to match the way `git checkout -b topic origin` picks a starting\n     + point.  Multiple _<branch>_ arguments are unioned.\n       \n      +`--prune-merged`::\n     -+\tDelete the local branches that `--forked` would list for\n     -+\tthe same _<branch>_ arguments, but only those whose tip is\n     -+\treachable from their configured upstream.\n     ++\tDelete the local branches that `--forked` would list for the\n     ++\tsame _<branch>_ arguments, but only those whose tip is\n     ++\treachable from their configured upstream.  In other words,\n     ++\tthe work on the branch has already landed on the upstream it\n     ++\ttracks, so the local copy is no longer needed.\n      ++\n     -+For arguments that refer to remote-tracking branches, run\n     -+`git fetch` first so reachability is checked against the\n     -+current upstream state; refs are read locally.\n     ++Reachability is checked against whatever the upstream refs say\n     ++locally; nothing is fetched.  Run `git fetch` first if you want\n     ++the upstream refs refreshed.\n      ++\n     -+The following branches are always preserved:\n     ++A branch is left alone if any of the following holds:\n     ++its upstream no longer resolves locally; it is checked out in any\n     ++worktree; or its push destination (`<branch>@{push}`) equals its\n     ++upstream (`<branch>@{upstream}`), so it cannot be distinguished\n     ++from a freshly pulled trunk that just looks \"fully merged\".\n      ++\n     -+--\n     -+* the currently checked-out branch in any worktree;\n     -+* any local branch whose name matches the default branch of\n     -+  any configured remote (the target of\n     -+  `refs/remotes/<remote>/HEAD`) -- typically `main` or\n     -+  `master`;\n     -+* any branch whose upstream no longer resolves locally.\n     -+--\n     ++Branches refused by the \"fully merged\" safety check are listed as\n     ++warnings and skipped; pass them to `git branch -D` explicitly if\n     ++you want them gone.\n      +\n       `-v`::\n       `-vv`::\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@\n     - #include \"branch.h\"\n     - #include \"path.h\"\n     - #include \"string-list.h\"\n     -+#include \"strvec.h\"\n     - #include \"column.h\"\n     - #include \"utf8.h\"\n     - #include \"ref-filter.h\"\n     -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     - \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     - \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     - \tN_(\"git branch [<options>] --forked <branch>...\"),\n     -+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n     - \tNULL\n     - };\n     - \n     -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     - \t * any of the following code, but during the transition period,\n     - \t * a gentle reminder is in order.\n     - \t */\n     --\tif (head_rev != reference_rev) {\n     --\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n     -+\tif (head_rev && head_rev != reference_rev) {\n     -+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n     - \t\tif (expect < 0)\n     - \t\t\texit(128);\n     - \t\tif (expect == merged)\n      @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n       \treturn 0;\n       }\n       \n     -+static int collect_default_branch_name(struct remote *remote, void *cb_data)\n     -+{\n     -+\tstruct string_list *protected = cb_data;\n     -+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     -+\tstruct strbuf head = STRBUF_INIT;\n     -+\tconst char *target;\n     -+\n     -+\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n     -+\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n     -+\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n     -+\tif (target) {\n     -+\t\tconst char *leaf = strrchr(target, '/');\n     -+\t\tif (leaf)\n     -+\t\t\tstring_list_insert(protected, leaf + 1);\n     -+\t}\n     -+\tstrbuf_release(&head);\n     -+\treturn 0;\n     +-static int list_forked_branches(int argc, const char **argv)\n     ++static void collect_forked_set(int argc, const char **argv,\n     ++\t\t\t       struct string_list *out)\n     + {\n     + \tstruct upstream_pattern *patterns = NULL;\n     + \tsize_t nr_patterns = 0;\n     +-\tstruct string_list out = STRING_LIST_INIT_DUP;\n     +-\tstruct string_list_item *item;\n     + \tstruct forked_cb cb;\n     + \n     +-\tif (!argc)\n     +-\t\tdie(_(\"--forked requires at least one <branch>\"));\n     +-\n     + \tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n     + \tcb.patterns = patterns;\n     + \tcb.nr_patterns = nr_patterns;\n     +-\tcb.out = &out;\n     ++\tcb.out = out;\n     + \n     + \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     + \t\t\t\t collect_forked_branch, &cb);\n     + \n     +-\tstring_list_sort(&out);\n     ++\tstring_list_sort(out);\n     ++\n     ++\tupstream_pattern_list_clear(patterns, nr_patterns);\n      +}\n      +\n     - static void collect_forked_set(int argc, const char **argv,\n     - \t\t\t       struct string_list *out)\n     - {\n     -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n     ++static int list_forked_branches(int argc, const char **argv)\n     ++{\n     ++\tstruct string_list out = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list_item *item;\n     ++\n     ++\tif (!argc)\n     ++\t\tdie(_(\"--forked requires at least one <branch>\"));\n     ++\n     ++\tcollect_forked_set(argc, argv, &out);\n     + \tfor_each_string_list_item(item, &out)\n     + \t\tputs(item->string);\n     + \n     +-\tupstream_pattern_list_clear(patterns, nr_patterns);\n     + \tstring_list_clear(&out, 0);\n       \treturn 0;\n       }\n       \n     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n      +\tstruct strvec deletable = STRVEC_INIT;\n     -+\tstruct strbuf buf = STRBUF_INIT;\n      +\tstruct string_list_item *item;\n     -+\tint n_not_merged = 0;\n      +\tint ret = 0;\n      +\n      +\tif (!argc)\n      +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n      +\n      +\tcollect_forked_set(argc, argv, &candidates);\n     -+\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n      +\n      +\tfor_each_string_list_item(item, &candidates) {\n      +\t\tconst char *short_name = item->string;\n     -+\t\tconst char *upstream = item->util;\n     -+\n     -+\t\tstrbuf_reset(&buf);\n     -+\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n     -+\t\tif (branch_checked_out(buf.buf))\n     ++\t\tstruct branch *branch = branch_get(short_name);\n     ++\t\tconst char *upstream, *push;\n     ++\t\tstruct strbuf full = STRBUF_INIT;\n     ++\t\tint skip;\n     ++\n     ++\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     ++\t\tskip = !!branch_checked_out(full.buf);\n     ++\t\tstrbuf_release(&full);\n     ++\t\tif (skip)\n      +\t\t\tcontinue;\n      +\n     -+\t\tif (string_list_has_string(&protected_default_names,\n     -+\t\t\t\t\t   short_name))\n     ++\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     ++\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n      +\t\t\tcontinue;\n     -+\n     -+\t\tif (!refs_ref_exists(refs, upstream))\n     ++\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n     ++\t\tif (!push || !strcmp(push, upstream))\n      +\t\t\tcontinue;\n      +\n      +\t\tstrvec_push(&deletable, short_name);\n      +\t}\n     -+\tstrbuf_release(&buf);\n      +\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n     -+\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n     -+\t\t\t\t      1, &n_not_merged);\n     -+\n     -+\tif (n_not_merged && !quiet)\n     -+\t\tfprintf(stderr,\n     -+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n     -+\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n     -+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n     -+\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n     -+\t\t\t   n_not_merged),\n     -+\t\t\tn_not_merged);\n     ++\t\t\t\t      0, /* force */\n     ++\t\t\t\t      FILTER_REFS_BRANCHES,\n     ++\t\t\t\t      quiet,\n     ++\t\t\t\t      1, /* warn_only */\n     ++\t\t\t\t      1, /* no_head_fallback */\n     ++\t\t\t\t      0  /* dry_run */);\n      +\n      +\tstrvec_clear(&deletable);\n     -+\tstring_list_clear(&candidates, 1);\n     -+\tstring_list_clear(&protected_default_names, 0);\n     ++\tstring_list_clear(&candidates, 0);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"forked\", &forked,\n       \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n      +\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     -+\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n     ++\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\tgit -C pm-upstream branch one HEAD~ &&\n      +\tgit -C pm-upstream branch two HEAD &&\n      +\tgit -C pm-upstream branch wip main &&\n     -+\tgit -C pm-upstream checkout main\n     ++\tgit -C pm-upstream checkout main &&\n     ++\ttest_create_repo pm-fork\n      +'\n      +\n      +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n      +\ttest_when_finished \"rm -rf pm-merged\" &&\n      +\tgit clone pm-upstream pm-merged &&\n     ++\tgit -C pm-merged remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-merged remote.pushDefault fork &&\n     ++\ttest_config -C pm-merged push.default current &&\n      +\tgit -C pm-merged branch one one-commit &&\n      +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n      +\tgit -C pm-merged branch two two-commit &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged with a literal upstream argument' '\n     ++test_expect_success '--prune-merged accepts a literal upstream' '\n      +\ttest_when_finished \"rm -rf pm-literal\" &&\n      +\tgit clone pm-upstream pm-literal &&\n     ++\tgit -C pm-literal remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-literal remote.pushDefault fork &&\n     ++\ttest_config -C pm-literal push.default current &&\n      +\tgit -C pm-literal branch one one-commit &&\n      +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-literal branch keepme one-commit &&\n     -+\tgit -C pm-literal branch --set-upstream-to=origin/main keepme &&\n      +\n      +\tgit -C pm-literal branch --prune-merged origin/next &&\n      +\n     -+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&\n     -+\tgit -C pm-literal rev-parse --verify refs/heads/keepme\n     ++\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n      +'\n      +\n      +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n      +\ttest_when_finished \"rm -rf pm-union\" &&\n      +\tgit clone pm-upstream pm-union &&\n     ++\tgit -C pm-union remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-union remote.pushDefault fork &&\n     ++\ttest_config -C pm-union push.default current &&\n      +\tgit -C pm-union branch one one-commit &&\n      +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n      +\tgit -C pm-union branch two base &&\n      +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n     ++\tgit -C pm-union checkout --detach &&\n      +\n      +\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged with a local-branch argument' '\n     -+\ttest_create_repo pm-local &&\n     ++test_expect_success '--prune-merged accepts a local upstream' '\n      +\ttest_when_finished \"rm -rf pm-local\" &&\n     -+\ttest_commit -C pm-local base &&\n     -+\tgit -C pm-local branch topic base &&\n     -+\tgit -C pm-local config branch.topic.remote . &&\n     -+\tgit -C pm-local config branch.topic.merge refs/heads/main &&\n     -+\tgit -C pm-local checkout --detach &&\n     -+\n     -+\tgit -C pm-local branch --prune-merged main &&\n     -+\n     -+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&\n     -+\tgit -C pm-local rev-parse --verify refs/heads/main\n     ++\tgit clone pm-upstream pm-local &&\n     ++\tgit -C pm-local remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-local remote.pushDefault fork &&\n     ++\ttest_config -C pm-local push.default current &&\n     ++\tgit -C pm-local checkout -b trunk &&\n     ++\tgit -C pm-local branch one one-commit &&\n     ++\tgit -C pm-local branch --set-upstream-to=trunk one &&\n     ++\tgit -C pm-local merge --ff-only one-commit &&\n     ++\n     ++\tgit -C pm-local branch --prune-merged trunk &&\n     ++\n     ++\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged spares branches with un-integrated commits' '\n     ++test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n      +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n      +\tgit clone pm-upstream pm-unmerged &&\n     ++\tgit -C pm-unmerged remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-unmerged remote.pushDefault fork &&\n     ++\ttest_config -C pm-unmerged push.default current &&\n      +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n      +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n      +\ttest_commit -C pm-unmerged local-only &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\n      +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n      +\ttest_grep \"not fully merged\" err &&\n     -+\ttest_grep \"Skipped 1 branch\" err &&\n     -+\ttest_grep \"git branch -D\" err &&\n      +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n      +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n      +'\n      +\n     ++test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n     ++\ttest_when_finished \"rm -rf pm-nohead\" &&\n     ++\tgit clone pm-upstream pm-nohead &&\n     ++\tgit -C pm-nohead remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-nohead remote.pushDefault fork &&\n     ++\ttest_config -C pm-nohead push.default current &&\n     ++\tgit -C pm-nohead branch topic one-commit &&\n     ++\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n     ++\n     ++\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n     ++\n     ++\ttest_grep ! \"not yet merged to HEAD\" err &&\n     ++\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n     ++'\n     ++\n      +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n      +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n      +\tgit clone pm-upstream pm-upstream-gone &&\n     ++\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n     ++\ttest_config -C pm-upstream-gone push.default current &&\n      +\tgit -C pm-upstream-gone branch one one-commit &&\n      +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +test_expect_success '--prune-merged never deletes the checked-out branch' '\n      +\ttest_when_finished \"rm -rf pm-head\" &&\n      +\tgit clone pm-upstream pm-head &&\n     ++\tgit -C pm-head remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-head remote.pushDefault fork &&\n     ++\ttest_config -C pm-head push.default current &&\n      +\tgit -C pm-head checkout -b one one-commit &&\n      +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\tgit -C pm-head rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged spares the local default branch' '\n     -+\ttest_when_finished \"rm -rf pm-default\" &&\n     -+\tgit clone pm-upstream pm-default &&\n     -+\tgit -C pm-default checkout --detach &&\n     -+\tgit -C pm-default branch --prune-merged \"origin/*\" &&\n     -+\tgit -C pm-default rev-parse --verify refs/heads/main\n     ++test_expect_success '--prune-merged spares branches that push back to their upstream' '\n     ++\ttest_when_finished \"rm -rf pm-push-eq\" &&\n     ++\tgit clone pm-upstream pm-push-eq &&\n     ++\tgit -C pm-push-eq checkout --detach &&\n     ++\n     ++\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n     ++\n     ++\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n      +'\n      +\n     -+test_expect_success '--prune-merged protects the default branch by name only' '\n     -+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n     -+\tgit clone pm-upstream pm-default-alias &&\n     -+\tgit -C pm-default-alias branch --track trunk origin/main &&\n     -+\tgit -C pm-default-alias checkout --detach &&\n     -+\tgit -C pm-default-alias branch --prune-merged \"origin/*\" &&\n     -+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n     -+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n     ++test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n     ++\ttest_when_finished \"rm -rf pm-push-branch\" &&\n     ++\tgit clone pm-upstream pm-push-branch &&\n     ++\tgit -C pm-push-branch remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-push-branch remote.pushDefault fork &&\n     ++\ttest_config -C pm-push-branch push.default current &&\n     ++\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n     ++\tgit -C pm-push-branch checkout --detach &&\n     ++\n     ++\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n     ++\n     ++\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n      +'\n      +\n     -+test_expect_success '--prune-merged with literal arg also protects default-name' '\n     -+\ttest_when_finished \"rm -rf pm-literal-default\" &&\n     -+\tgit clone pm-upstream pm-literal-default &&\n     -+\tgit -C pm-literal-default checkout --detach &&\n     -+\tgit -C pm-literal-default branch --prune-merged origin/main &&\n     -+\tgit -C pm-literal-default rev-parse --verify refs/heads/main\n     ++test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n     ++\ttest_when_finished \"rm -rf pm-push-diff\" &&\n     ++\tgit clone pm-upstream pm-push-diff &&\n     ++\tgit -C pm-push-diff remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-push-diff remote.pushDefault fork &&\n     ++\ttest_config -C pm-push-diff push.default current &&\n     ++\tgit -C pm-push-diff branch topic one-commit &&\n     ++\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n     ++\tgit -C pm-push-diff checkout --detach &&\n     ++\n     ++\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n     ++\n     ++\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n      +'\n      +\n      +test_expect_success '--prune-merged requires at least one <branch>' '\n     -+\ttest_must_fail git -C pm-upstream branch --prune-merged 2>err &&\n     ++\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n      +\ttest_grep \"at least one <branch>\" err\n      +'\n      +\n 3:  6e38d7af3a ! 5:  75b6d2366a branch: add branch.<name>.pruneMerged opt-out\n     @@ Metadata\n       ## Commit message ##\n          branch: add branch.<name>.pruneMerged opt-out\n      \n     -    Setting branch.<name>.pruneMerged=false exempts that branch\n     -    from --prune-merged. Useful for topic branches you intend to\n     -    develop further after an initial round has been merged\n     +    Setting branch.<name>.pruneMerged=false exempts that branch from\n     +    \"git branch --prune-merged\". Useful for a topic branch you want\n     +    to develop further after an initial round has been merged\n          upstream.\n      \n     -    Explicit deletion via 'git branch -d' is unaffected.\n     +    Unless --quiet is given, the skip is reported per branch so the\n     +    user knows why their topic was preserved.\n     +\n     +    Explicit deletion via \"git branch -d\" continues to consult the\n     +    normal merge check and is not affected by this setting.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/config/branch.adoc: for details).\n      +\n      +`branch.<name>.pruneMerged`::\n      +\tIf set to `false`, branch _<name>_ is exempt from\n     -+\t`git branch --prune-merged`. Defaults to true. Explicit\n     -+\tdeletion via `git branch -d` is unaffected.\n     ++\t`git branch --prune-merged`.  Useful for a topic branch you\n     ++\tintend to develop further after an initial round has been\n     ++\tmerged upstream.  Defaults to true.  Explicit deletion via\n     ++\t`git branch -d` is unaffected.\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: The following branches are always preserved:\n     -   any configured remote (the target of\n     -   `refs/remotes/<remote>/HEAD`) -- typically `main` or\n     -   `master`;\n     -+* any branch with `branch.<name>.pruneMerged` set to `false`;\n     - * any branch whose upstream no longer resolves locally.\n     - --\n     - \n     +@@ Documentation/git-branch.adoc: the upstream refs refreshed.\n     + +\n     + A branch is left alone if any of the following holds:\n     + its upstream no longer resolves locally; it is checked out in any\n     +-worktree; or its push destination (`<branch>@{push}`) equals its\n     ++worktree; its push destination (`<branch>@{push}`) equals its\n     + upstream (`<branch>@{upstream}`), so it cannot be distinguished\n     +-from a freshly pulled trunk that just looks \"fully merged\".\n     ++from a freshly pulled trunk that just looks \"fully merged\"; or\n     ++`branch.<name>.pruneMerged` is set to `false`.\n     + +\n     + Branches refused by the \"fully merged\" safety check are listed as\n     + warnings and skipped; pass them to `git branch -D` explicitly if\n      \n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     - \tfor_each_string_list_item(item, &candidates) {\n     - \t\tconst char *short_name = item->string;\n     - \t\tconst char *upstream = item->util;\n     -+\t\tint prune_allowed = 1;\n     + \t\tstruct branch *branch = branch_get(short_name);\n     + \t\tconst char *upstream, *push;\n     + \t\tstruct strbuf full = STRBUF_INIT;\n     ++\t\tstruct strbuf key = STRBUF_INIT;\n     + \t\tint skip;\n     ++\t\tint opt_out;\n       \n     - \t\tstrbuf_reset(&buf);\n     - \t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n     + \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     + \t\tskip = !!branch_checked_out(full.buf);\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     - \t\tif (!refs_ref_exists(refs, upstream))\n     + \t\tif (!push || !strcmp(push, upstream))\n       \t\t\tcontinue;\n       \n     -+\t\tstrbuf_reset(&buf);\n     -+\t\tstrbuf_addf(&buf, \"branch.%s.prunemerged\", short_name);\n     -+\t\tif (!repo_config_get_bool(the_repository, buf.buf,\n     -+\t\t\t\t\t  &prune_allowed) &&\n     -+\t\t    !prune_allowed) {\n     ++\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n     ++\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n     ++\t\t    !opt_out) {\n      +\t\t\tif (!quiet)\n     -+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n     -+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n     ++\t\t\t\tfprintf(stderr,\n     ++\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n     ++\t\t\tstrbuf_release(&key);\n      +\t\t\tcontinue;\n      +\t\t}\n     ++\t\tstrbuf_release(&key);\n      +\n       \t\tstrvec_push(&deletable, short_name);\n       \t}\n     - \tstrbuf_release(&buf);\n     + \n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br\n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n      +\ttest_when_finished \"rm -rf pm-optout\" &&\n      +\tgit clone pm-upstream pm-optout &&\n     ++\tgit -C pm-optout remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-optout remote.pushDefault fork &&\n     ++\ttest_config -C pm-optout push.default current &&\n      +\tgit -C pm-optout branch one one-commit &&\n      +\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n      +\tgit -C pm-optout branch two two-commit &&\n      +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n     -+\tgit -C pm-optout config branch.one.pruneMerged false &&\n     ++\ttest_config -C pm-optout branch.one.pruneMerged false &&\n      +\n      +\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br\n      +\tgit clone pm-upstream pm-optout-d &&\n      +\tgit -C pm-optout-d branch one one-commit &&\n      +\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n     ++\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n      +\n      +\tgit -C pm-optout-d branch -d one &&\n      +\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n 4:  c68d162e22 ! 6:  a1a42a6b19 branch: add --dry-run for --prune-merged\n     @@ Metadata\n       ## Commit message ##\n          branch: add --dry-run for --prune-merged\n      \n     -    With --dry-run, --prune-merged prints the branches it would\n     -    delete and exits without touching any ref. Useful for\n     -    sanity-checking a glob like 'origin/*' before letting it run.\n     +    With --dry-run, --prune-merged prints the local branches it would\n     +    delete -- one \"Would delete branch <name>\" line per candidate --\n     +    and exits without touching any ref.\n     +\n     +    This is the natural sanity check before letting a broad pattern\n     +    like 'origin/*' run for real: the @{push}-vs-@{upstream} and\n     +    unmerged filtering still applies, so the dry-run output is\n     +    exactly the set that the live run would delete.\n     +\n     +    --dry-run is only meaningful in combination with --prune-merged\n     +    and is rejected otherwise.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: The following branches are always preserved:\n     - * any branch whose upstream no longer resolves locally.\n     - --\n     +@@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety check are listed as\n     + warnings and skipped; pass them to `git branch -D` explicitly if\n     + you want them gone.\n       \n      +`--dry-run`::\n     -+\tWith `--prune-merged`, print the branches that would be\n     -+\tdeleted instead of deleting them.\n     ++\tWith `--prune-merged`, print which branches would be\n     ++\tdeleted and exit without touching any ref.  Useful for\n     ++\tsanity-checking a wide pattern like `'origin/*'` before\n     ++\tcommitting to the deletion.\n      +\n       `-v`::\n       `-vv`::\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     - \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     - \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     - \tN_(\"git branch [<options>] --forked <branch>...\"),\n     --\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n     -+\tN_(\"git branch [<options>] --prune-merged [--dry-run] <branch>...\"),\n     - \tNULL\n     - };\n     - \n     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     - }\n     - \n     - static int delete_branches(int argc, const char **argv, int force, int kinds,\n     --\t\t\t   int quiet, int warn_only, int *n_not_merged)\n     -+\t\t\t   int quiet, int warn_only, int dry_run,\n     -+\t\t\t   int *n_not_merged)\n     - {\n     - \tstruct commit *head_rev = NULL;\n     - \tstruct object_id oid;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     - \t\t\tgoto next;\n     - \t\t}\n     - \n     -+\t\tif (dry_run) {\n     -+\t\t\tprintf(_(\"Would delete branch '%s'\\n\"),\n     -+\t\t\t       name + branch_name_pos);\n     -+\t\t\tgoto next;\n     -+\t\t}\n     -+\n     - \t\titem = string_list_append(&refs_to_delete, name);\n     - \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n     - \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n      @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n       \treturn 0;\n       }\n       \n      -static int prune_merged_branches(int argc, const char **argv, int quiet)\n     -+static int prune_merged_branches(int argc, const char **argv,\n     -+\t\t\t\t int dry_run, int quiet)\n     ++static int prune_merged_branches(int argc, const char **argv, int quiet,\n     ++\t\t\t\t int dry_run)\n       {\n       \tstruct ref_store *refs = get_main_ref_store(the_repository);\n       \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     - \tif (deletable.nr)\n     - \t\tret = delete_branches(deletable.nr, deletable.v,\n     - \t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n     --\t\t\t\t      1, &n_not_merged);\n     -+\t\t\t\t      1, dry_run, &n_not_merged);\n     + \t\t\t\t      quiet,\n     + \t\t\t\t      1, /* warn_only */\n     + \t\t\t\t      1, /* no_head_fallback */\n     +-\t\t\t\t      0  /* dry_run */);\n     ++\t\t\t\t      dry_run);\n       \n     - \tif (n_not_merged && !quiet)\n     - \t\tfprintf(stderr,\n     + \tstrvec_clear(&deletable);\n     + \tstring_list_clear(&candidates, 0);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n       \tint forked = 0;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n       \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     - \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n     + \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n     -+\t\t\tN_(\"with --prune-merged, only print what would be deleted\")),\n     ++\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n     - \t\t\t     0);\n     + \tif (noncreate_actions > 1)\n     + \t\tusage_with_options(builtin_branch_usage, options);\n       \n      +\tif (dry_run && !prune_merged)\n      +\t\tdie(_(\"--dry-run requires --prune-merged\"));\n      +\n     - \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n     - \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n     - \t    argc == 0)\n     + \tif (recurse_submodules_explicit) {\n     + \t\tif (!submodule_propagate_branches)\n     + \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tif (!argc)\n     - \t\t\tdie(_(\"branch name required\"));\n     - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     --\t\t\t\t      quiet, 0, NULL);\n     -+\t\t\t\t      quiet, 0, 0, NULL);\n     - \t\tgoto out;\n     - \t} else if (forked) {\n       \t\tret = list_forked_branches(argc, argv);\n       \t\tgoto out;\n       \t} else if (prune_merged) {\n      -\t\tret = prune_merged_branches(argc, argv, quiet);\n     -+\t\tret = prune_merged_branches(argc, argv, dry_run, quiet);\n     ++\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n       \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n       '\n       \n     -+test_expect_success '--prune-merged --dry-run prints but does not delete' '\n     -+\ttest_when_finished \"rm -rf pm-dryrun\" &&\n     -+\tgit clone pm-upstream pm-dryrun &&\n     -+\tgit -C pm-dryrun branch one one-commit &&\n     -+\tgit -C pm-dryrun branch --set-upstream-to=origin/next one &&\n     ++test_expect_success '--prune-merged --dry-run lists but does not delete' '\n     ++\ttest_when_finished \"rm -rf pm-dry\" &&\n     ++\tgit clone pm-upstream pm-dry &&\n     ++\tgit -C pm-dry remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-dry remote.pushDefault fork &&\n     ++\ttest_config -C pm-dry push.default current &&\n     ++\tgit -C pm-dry branch one one-commit &&\n     ++\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-dry branch two two-commit &&\n     ++\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n     ++\n     ++\tgit -C pm-dry branch --prune-merged --dry-run \"origin/*\" >actual &&\n     ++\ttest_grep \"Would delete branch one \" actual &&\n     ++\ttest_grep \"Would delete branch two \" actual &&\n      +\n     -+\tgit -C pm-dryrun branch --prune-merged --dry-run \"origin/*\" >out &&\n     -+\ttest_grep \"Would delete branch .one.\" out &&\n     -+\tgit -C pm-dryrun rev-parse --verify refs/heads/one\n     ++\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n     ++\tgit -C pm-dry rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '\n     -+\ttest_when_finished \"rm -rf pm-dryrun-unmerged\" &&\n     -+\tgit clone pm-upstream pm-dryrun-unmerged &&\n     -+\tgit -C pm-dryrun-unmerged checkout -b wip origin/next &&\n     -+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&\n     -+\ttest_commit -C pm-dryrun-unmerged local-only &&\n     -+\tgit -C pm-dryrun-unmerged checkout - &&\n     -+\tgit -C pm-dryrun-unmerged branch merged one-commit &&\n     -+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&\n     ++test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n     ++\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n     ++\tgit clone pm-upstream pm-dry-mixed &&\n     ++\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n     ++\ttest_config -C pm-dry-mixed push.default current &&\n     ++\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n     ++\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n     ++\ttest_commit -C pm-dry-mixed local-only &&\n     ++\tgit -C pm-dry-mixed checkout - &&\n     ++\tgit -C pm-dry-mixed branch merged one-commit &&\n     ++\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n      +\n     -+\tgit -C pm-dryrun-unmerged branch --prune-merged --dry-run \"origin/*\" \\\n     -+\t\t>out 2>err &&\n     -+\ttest_grep \"Would delete branch .merged.\" out &&\n     -+\ttest_grep ! \"Would delete branch .wip.\" out &&\n     -+\ttest_grep \"not fully merged\" err &&\n     -+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&\n     -+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged\n     ++\tgit -C pm-dry-mixed branch --prune-merged --dry-run \"origin/*\" >out &&\n     ++\ttest_grep \"Would delete branch merged\" out &&\n     ++\ttest_grep ! \"Would delete branch wip\" out &&\n     ++\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n     ++\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n      +'\n      +\n     -+test_expect_success '--dry-run requires --prune-merged' '\n     -+\ttest_must_fail git -C pm-upstream branch --dry-run 2>err &&\n     ++test_expect_success '--dry-run without --prune-merged is rejected' '\n     ++\ttest_must_fail git -C forked branch --dry-run 2>err &&\n      +\ttest_grep \"requires --prune-merged\" err\n      +'\n      +\n\n-- \ngitgitgadget\n"},{"id":"543909","messageId":"6e6580270e05c868c4b5e683ab7232d2dd297251.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:35Z","receivedAt":"2026-05-22T11:31:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd no_head_fallback and dry_run flags to delete_branches() so a bulk\ncaller (the upcoming --prune-merged) can ask strictly about\nmerged-into-upstream without a silent fallback to HEAD, and rehearse\ndeletions with the same \"Would delete branch ...\" wording as the live\nrun.  Existing callers pass 0 for both and keep current behavior.\n\nWhen no_head_fallback is set, head_rev stays NULL through to\nbranch_merged(), whose \"merged to X but not yet merged to HEAD\"\nreminder otherwise compares against HEAD.  That reminder is only\nmeaningful when the caller actually cares about HEAD; for the\nbulk caller every candidate is known to have an upstream and HEAD\nis irrelevant to the decision.  Guard the block on head_rev so the\nNULL case skips it instead of treating \"NULL != reference_rev\" as\n\"diverges from HEAD\" and emitting a spurious warning.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 27 +++++++++++++++++++--------\n 1 file changed, 19 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 96f6ae6dec..08c1237624 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -170,10 +170,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -226,7 +229,8 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet, int warn_only)\n+\t\t\t   int quiet, int warn_only, int no_head_fallback,\n+\t\t\t   int dry_run)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -260,7 +264,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -331,13 +335,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n@@ -1027,7 +1038,7 @@ int cmd_branch(int argc,\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n-\t\t\t\t      quiet, 0);\n+\t\t\t\t      quiet, 0, 0, 0);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543911","messageId":"b666d09bf52a00f678c51bad0171074b5629bf98.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:34Z","receivedAt":"2026-05-22T11:31:49Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a warn_only flag to delete_branches() and check_branch_commit()\nso a bulk caller can report not-fully-merged branches as one-line\nwarnings and continue, instead of erroring with the four-line \"use\n'git branch -D'\" advice that the standalone \"git branch -d\" path\nemits.  Default callers pass 0 and are unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 26 +++++++++++++++++---------\n 1 file changed, 17 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2d34ad34dc..96f6ae6dec 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -193,7 +193,7 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -201,10 +201,16 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -220,7 +226,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -310,8 +316,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -1019,7 +1026,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0);\n \t\tgoto out;\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n-- \ngitgitgadget\n\n"},{"id":"543912","messageId":"a1a42a6b1950bf49762b9f8d6555210aa5119b10.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 6/6] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:38Z","receivedAt":"2026-05-22T11:31:50Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --prune-merged prints the local branches it would\ndelete -- one \"Would delete branch <name>\" line per candidate --\nand exits without touching any ref.\n\nThis is the natural sanity check before letting a broad pattern\nlike 'origin/*' run for real: the @{push}-vs-@{upstream} and\nunmerged filtering still applies, so the dry-run output is\nexactly the set that the live run would delete.\n\n--dry-run is only meaningful in combination with --prune-merged\nand is rejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 13 ++++++++---\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 61 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 1bd28c4e37..ee9a6354fd 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <branch>...\n-git branch --prune-merged <branch>...\n+git branch --prune-merged [--dry-run] <branch>...\n \n DESCRIPTION\n -----------\n@@ -235,6 +235,12 @@ Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\n you want them gone.\n \n+`--dry-run`::\n+\tWith `--prune-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 187d5d1563..7a2db11cd4 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -857,7 +857,8 @@ static int list_forked_branches(int argc, const char **argv)\n \treturn 0;\n }\n \n-static int prune_merged_branches(int argc, const char **argv, int quiet)\n+static int prune_merged_branches(int argc, const char **argv, int quiet,\n+\t\t\t\t int dry_run)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n@@ -914,7 +915,7 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \t\t\t\t      quiet,\n \t\t\t\t      1, /* warn_only */\n \t\t\t\t      1, /* no_head_fallback */\n-\t\t\t\t      0  /* dry_run */);\n+\t\t\t\t      dry_run);\n \n \tstrvec_clear(&deletable);\n \tstring_list_clear(&candidates, 0);\n@@ -964,6 +965,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n \tint prune_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1021,6 +1023,8 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1079,6 +1083,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !prune_merged)\n+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1120,7 +1127,7 @@ int cmd_branch(int argc,\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex da7e174e09..0e0629d19e 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2020,4 +2020,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dry\" &&\n+\tgit clone pm-upstream pm-dry &&\n+\tgit -C pm-dry remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry remote.pushDefault fork &&\n+\ttest_config -C pm-dry push.default current &&\n+\tgit -C pm-dry branch one one-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-dry branch two two-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-dry branch --prune-merged --dry-run \"origin/*\" >actual &&\n+\ttest_grep \"Would delete branch one \" actual &&\n+\ttest_grep \"Would delete branch two \" actual &&\n+\n+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n+\tgit -C pm-dry rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n+\tgit clone pm-upstream pm-dry-mixed &&\n+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n+\ttest_config -C pm-dry-mixed push.default current &&\n+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dry-mixed local-only &&\n+\tgit -C pm-dry-mixed checkout - &&\n+\tgit -C pm-dry-mixed branch merged one-commit &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dry-mixed branch --prune-merged --dry-run \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch merged\" out &&\n+\ttest_grep ! \"Would delete branch wip\" out &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run without --prune-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --prune-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"543914","messageId":"75b6d2366a3aa6e8eaa3d7e657265fa3afc6ad5b.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:37Z","receivedAt":"2026-05-22T11:31:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n\"git branch --prune-merged\". Useful for a topic branch you want\nto develop further after an initial round has been merged\nupstream.\n\nUnless --quiet is given, the skip is reported per branch so the\nuser knows why their topic was preserved.\n\nExplicit deletion via \"git branch -d\" continues to consult the\nnormal merge check and is not affected by this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 ++++++++++++++\n t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++\n 4 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..6c1b5bb9cd 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c521b5f4ca..1bd28c4e37 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -226,9 +226,10 @@ the upstream refs refreshed.\n +\n A branch is left alone if any of the following holds:\n its upstream no longer resolves locally; it is checked out in any\n-worktree; or its push destination (`<branch>@{push}`) equals its\n+worktree; its push destination (`<branch>@{push}`) equals its\n upstream (`<branch>@{upstream}`), so it cannot be distinguished\n-from a freshly pulled trunk that just looks \"fully merged\".\n+from a freshly pulled trunk that just looks \"fully merged\"; or\n+`branch.<name>.pruneMerged` is set to `false`.\n +\n Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1569f29573..187d5d1563 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -875,7 +875,9 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \t\tstruct branch *branch = branch_get(short_name);\n \t\tconst char *upstream, *push;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tint skip;\n+\t\tint opt_out;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tskip = !!branch_checked_out(full.buf);\n@@ -890,6 +892,18 @@ static int prune_merged_branches(int argc, const char **argv, int quiet)\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex ad87946081..da7e174e09 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1990,4 +1990,34 @@ test_expect_success '--prune-merged requires at least one <branch>' '\n \ttest_grep \"at least one <branch>\" err\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout remote add fork ../pm-fork &&\n+\ttest_config -C pm-optout remote.pushDefault fork &&\n+\ttest_config -C pm-optout push.default current &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543915","messageId":"e7e03c13385d2a27a3c332a78c204dc48ad65f69.1779449498.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v11 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-05-22T11:31:36Z","receivedAt":"2026-05-22T11:31:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --prune-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nrestricted to those whose tip is reachable from their configured\nupstream: the work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nReachability is read from the local refs only -- nothing is\nfetched. Users who want fresh upstream refs run \"git fetch\" first;\nthe deletion path stays a separate, idempotent step that also\nworks offline.\n\nThree classes of branches are spared:\n\n  * any branch checked out in any worktree;\n  * any branch whose upstream no longer resolves locally (its\n    disappearance is not, on its own, evidence of integration);\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} == <branch>@{upstream}). Such a branch\n    cannot be distinguished from a freshly pulled trunk that\n    just looks \"fully merged\" -- e.g. local \"main\" tracking and\n    pushing to \"origin/main\" right after a pull. Only branches\n    that push somewhere other than their upstream (typically\n    topics in a fork-based workflow) are treated as candidates.\n\nDeletion goes through the existing delete_branches() in warn-only\nmode and with the HEAD-fallback disabled: a branch that is not\nyet fully merged to its upstream is reported as a one-line warning\nand skipped, so a single un-mergeable topic does not abort the\nwhole sweep, and there is no fallback to \"merged into the\ncurrently checked out branch\" -- we only act on upstream-merged\nstatus.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  22 ++++\n builtin/branch.c              |  87 ++++++++++++++--\n t/t3200-branch.sh             | 183 ++++++++++++++++++++++++++++++++++\n 3 files changed, 281 insertions(+), 11 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex a37d3a12cb..c521b5f4ca 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n git branch --forked <branch>...\n+git branch --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -212,6 +213,27 @@ wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n to match the way `git checkout -b topic origin` picks a starting\n point.  Multiple _<branch>_ arguments are unioned.\n \n+`--prune-merged`::\n+\tDelete the local branches that `--forked` would list for the\n+\tsame _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream.  In other words,\n+\tthe work on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed.\n++\n+Reachability is checked against whatever the upstream refs say\n+locally; nothing is fetched.  Run `git fetch` first if you want\n+the upstream refs refreshed.\n++\n+A branch is left alone if any of the following holds:\n+its upstream no longer resolves locally; it is checked out in any\n+worktree; or its push destination (`<branch>@{push}`) equals its\n+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n+from a freshly pulled trunk that just looks \"fully merged\".\n++\n+Branches refused by the \"fully merged\" safety check are listed as\n+warnings and skipped; pass them to `git branch -D` explicitly if\n+you want them gone.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 08c1237624..1569f29573 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -821,34 +821,92 @@ static int collect_forked_branch(const struct reference *ref, void *cb_data)\n \treturn 0;\n }\n \n-static int list_forked_branches(int argc, const char **argv)\n+static void collect_forked_set(int argc, const char **argv,\n+\t\t\t       struct string_list *out)\n {\n \tstruct upstream_pattern *patterns = NULL;\n \tsize_t nr_patterns = 0;\n-\tstruct string_list out = STRING_LIST_INIT_DUP;\n-\tstruct string_list_item *item;\n \tstruct forked_cb cb;\n \n-\tif (!argc)\n-\t\tdie(_(\"--forked requires at least one <branch>\"));\n-\n \tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n \tcb.patterns = patterns;\n \tcb.nr_patterns = nr_patterns;\n-\tcb.out = &out;\n+\tcb.out = out;\n \n \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n \t\t\t\t collect_forked_branch, &cb);\n \n-\tstring_list_sort(&out);\n+\tstring_list_sort(out);\n+\n+\tupstream_pattern_list_clear(patterns, nr_patterns);\n+}\n+\n+static int list_forked_branches(int argc, const char **argv)\n+{\n+\tstruct string_list out = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--forked requires at least one <branch>\"));\n+\n+\tcollect_forked_set(argc, argv, &out);\n \tfor_each_string_list_item(item, &out)\n \t\tputs(item->string);\n \n-\tupstream_pattern_list_clear(patterns, nr_patterns);\n \tstring_list_clear(&out, 0);\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv, int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n+\n+\tcollect_forked_set(argc, argv, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct branch *branch = branch_get(short_name);\n+\t\tconst char *upstream, *push;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tint skip;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tskip = !!branch_checked_out(full.buf);\n+\t\tstrbuf_release(&full);\n+\t\tif (skip)\n+\t\t\tcontinue;\n+\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      0, /* force */\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      quiet,\n+\t\t\t\t      1, /* warn_only */\n+\t\t\t\t      1, /* no_head_fallback */\n+\t\t\t\t      0  /* dry_run */);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -891,6 +949,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint forked = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -946,6 +1005,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"forked\", &forked,\n \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -990,7 +1051,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && !forked && argc == 0)\n+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -999,7 +1061,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream + !!forked;\n+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -1043,6 +1105,9 @@ int cmd_branch(int argc,\n \t} else if (forked) {\n \t\tret = list_forked_branches(argc, argv);\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 013ddfb65d..ad87946081 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1807,4 +1807,187 @@ test_expect_success '--forked requires at least one <branch>' '\n \ttest_grep \"at least one <branch>\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main &&\n+\ttest_create_repo pm-fork\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged remote add fork ../pm-fork &&\n+\ttest_config -C pm-merged remote.pushDefault fork &&\n+\ttest_config -C pm-merged push.default current &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a literal upstream' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal remote add fork ../pm-fork &&\n+\ttest_config -C pm-literal remote.pushDefault fork &&\n+\ttest_config -C pm-literal push.default current &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-literal branch --prune-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union remote add fork ../pm-fork &&\n+\ttest_config -C pm-union remote.pushDefault fork &&\n+\ttest_config -C pm-union push.default current &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-union checkout --detach &&\n+\n+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a local upstream' '\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\tgit clone pm-upstream pm-local &&\n+\tgit -C pm-local remote add fork ../pm-fork &&\n+\ttest_config -C pm-local remote.pushDefault fork &&\n+\ttest_config -C pm-local push.default current &&\n+\tgit -C pm-local checkout -b trunk &&\n+\tgit -C pm-local branch one one-commit &&\n+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n+\tgit -C pm-local merge --ff-only one-commit &&\n+\n+\tgit -C pm-local branch --prune-merged trunk &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n+\ttest_config -C pm-unmerged push.default current &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n+\ttest_when_finished \"rm -rf pm-nohead\" &&\n+\tgit clone pm-upstream pm-nohead &&\n+\tgit -C pm-nohead remote add fork ../pm-fork &&\n+\ttest_config -C pm-nohead remote.pushDefault fork &&\n+\ttest_config -C pm-nohead push.default current &&\n+\tgit -C pm-nohead branch topic one-commit &&\n+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n+\n+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n+\n+\ttest_grep ! \"not yet merged to HEAD\" err &&\n+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n+\ttest_config -C pm-upstream-gone push.default current &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head remote add fork ../pm-fork &&\n+\ttest_config -C pm-head remote.pushDefault fork &&\n+\ttest_config -C pm-head push.default current &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n+\tgit clone pm-upstream pm-push-eq &&\n+\tgit -C pm-push-eq checkout --detach &&\n+\n+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n+\tgit clone pm-upstream pm-push-branch &&\n+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n+\ttest_config -C pm-push-branch push.default current &&\n+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n+\tgit -C pm-push-branch checkout --detach &&\n+\n+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n+\tgit clone pm-upstream pm-push-diff &&\n+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n+\ttest_config -C pm-push-diff push.default current &&\n+\tgit -C pm-push-diff branch topic one-commit &&\n+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n+\tgit -C pm-push-diff checkout --detach &&\n+\n+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n+\ttest_grep \"at least one <branch>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"543917","messageId":"xmqq4ijzu03h.fsf@gitster.g","threadId":"65574","inReplyTo":"CAHwyqnXVnFOeKRXQHHZMLbj_WL55A5gPxof=VFhZxDeXTT8kqw@mail.gmail.com","subject":"Re: [PATCH v10 2/4] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T11:58:58Z","receivedAt":"2026-05-22T11:59:00Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n>> Please discard this version.  I had unnecessary draft comments that\n>> I used as reference in it.\n>\n> I'm taking this to mean starting over from v9 and implementing the\n> 'origin/*' idea again from there. Correct?\n\nNo, what I meant was \"please discard the review message I was\nresponding to\".\n\n"},{"id":"544503","messageId":"9b44d867-219a-4ca3-b8ae-67fdac1c72f6@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"Re: [PATCH v11 0/6] branch: prune-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-02T13:05:03Z","receivedAt":"2026-06-02T13:05:09Z","isPatch":true,"body":"Hi Harald\n\nJust a quick note to say I've not forgotten about this, hopefully I \nshould have time to review it later in the week now I'm back on the list.\n\nThanks\n\nPhillip\n\nOn 22/05/2026 12:31, Harald Nordgren via GitGitGadget wrote:\n> After releasing v10, I hard-reset back to v9 and reworked the series from\n> there.\n> \n>   * The flags now take a branch, not a remote. --forked and --prune-merged\n>     accept a literal upstream short name like origin/main or a wildmatch\n>     pattern like origin/*. The old --all-remotes flag is gone, since origin/*\n>     covers that case.\n>   * The prune guard now compares @{push} against @{upstream}. A branch is\n>     spared when these are equal. That is the trunk like case, such as local\n>     main tracking and pushing to origin/main, where \"fully merged to\n>     upstream\" cannot be told apart from \"just pulled\". Only branches that\n>     push somewhere other than their upstream, typically fork based topics,\n>     are candidates. The earlier <remote>/HEAD by name guard that the reviewer\n>     rejected is gone.\n>   * New --dry-run for --prune-merged.\n> \n> Harald Nordgren (6):\n>    branch: add --forked <branch>\n>    branch: let delete_branches warn instead of error on bulk refusal\n>    branch: prepare delete_branches for a bulk caller\n>    branch: add --prune-merged <branch>\n>    branch: add branch.<name>.pruneMerged opt-out\n>    branch: add --dry-run for --prune-merged\n> \n>   Documentation/config/branch.adoc |   7 +\n>   Documentation/git-branch.adoc    |  42 ++++\n>   builtin/branch.c                 | 303 +++++++++++++++++++++++++--\n>   t/t3200-branch.sh                | 347 +++++++++++++++++++++++++++++++\n>   4 files changed, 682 insertions(+), 17 deletions(-)\n> \n> \n> base-commit: aec3f587505a472db67e9462d0702e7d463a449d\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v11\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v11\n> Pull-Request: https://github.com/git/git/pull/2285\n> \n> Range-diff vs v10:\n> \n>   1:  f2df159830 ! 1:  b9fddd124a branch: add --forked <branch>\n>       @@ Metadata\n>         ## Commit message ##\n>            branch: add --forked <branch>\n>        \n>       -            git branch --forked <branch>...\n>       +    List local branches whose configured upstream\n>       +    (branch.<name>.merge resolved against branch.<name>.remote)\n>       +    matches any of the given <branch> arguments.\n>        \n>       -    lists local branches whose configured upstream matches any\n>       -    of the given <branch> arguments.\n>       +    Each <branch> is interpreted against the local repository, not\n>       +    against any specific remote:\n>        \n>       -    Each <branch> is resolved to the same kind of ref that\n>       -    branch.<name>.remote and branch.<name>.merge together point at:\n>       -    a remote-tracking branch (e.g. origin/master), or, for branches\n>       -    tracking a local upstream, a local branch (e.g. master).\n>       -    Shell-style globs are also accepted (e.g. 'origin/*'). Multiple\n>       -    arguments are unioned.\n>       +      * a literal upstream short name, e.g. \"origin/main\" or \"master\"\n>       +        for a branch whose upstream is local;\n>       +      * a wildmatch pattern, e.g. \"origin/*\";\n>       +      * a bare configured-remote name, e.g. \"origin\", which resolves\n>       +        to whatever refs/remotes/origin/HEAD points at, matching how\n>       +        \"git checkout -b topic origin\" picks a starting point.\n>        \n>       -    This is the building block for --prune-merged.\n>       +    The literal-vs-wildcard distinction is settled at parse time so\n>       +    the per-branch matching loop calls wildmatch() only for genuine\n>       +    wildcards. Multiple <branch> arguments are unioned. Output is\n>       +    sorted by branch name.\n>       +\n>       +    This is the building block for --prune-merged, which deletes the\n>       +    listed branches once they have landed on their upstream.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n>         \tnothing is printed.\n>         \n>        +`--forked`::\n>       -+\tList local branches whose configured upstream matches any\n>       -+\tof the given _<branch>_ arguments. Each argument is either\n>       -+\ta ref (e.g. `origin/master`, `master`) or a shell-style\n>       -+\tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n>       ++\tList local branches whose configured upstream\n>       ++\t(`branch.<name>.merge` resolved against `branch.<name>.remote`)\n>       ++\tmatches any of the given _<branch>_ arguments.\n>       +++\n>       ++Each _<branch>_ is interpreted against the local repository: a literal\n>       ++upstream like `origin/main` or a local branch like `master`, or a\n>       ++wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n>       ++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,\n>       ++to match the way `git checkout -b topic origin` picks a starting\n>       ++point.  Multiple _<branch>_ arguments are unioned.\n>        +\n>         `-v`::\n>         `-vv`::\n>       @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n>         \tNULL\n>         };\n>         \n>       -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n>       -\n>       - static int check_branch_commit(const char *branchname, const char *refname,\n>       - \t\t\t       const struct object_id *oid, struct commit *head_rev,\n>       --\t\t\t       int kinds, int force)\n>       -+\t\t\t       int kinds, int force, int warn_only,\n>       -+\t\t\t       int *n_not_merged)\n>       - {\n>       - \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n>       - \tif (!force && !rev) {\n>       -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n>       - \t\treturn -1;\n>       - \t}\n>       - \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n>       --\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n>       --\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n>       --\t\t\t\t  _(\"If you are sure you want to delete it, \"\n>       --\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n>       -+\t\tif (warn_only) {\n>       -+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n>       -+\t\t\t\tbranchname);\n>       -+\t\t} else {\n>       -+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n>       -+\t\t\t      branchname);\n>       -+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n>       -+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n>       -+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n>       -+\t\t}\n>       -+\t\tif (n_not_merged)\n>       -+\t\t\t(*n_not_merged)++;\n>       - \t\treturn -1;\n>       - \t}\n>       - \treturn 0;\n>       -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n>       - }\n>       -\n>       - static int delete_branches(int argc, const char **argv, int force, int kinds,\n>       --\t\t\t   int quiet)\n>       -+\t\t\t   int quiet, int warn_only, int *n_not_merged)\n>       - {\n>       - \tstruct commit *head_rev = NULL;\n>       - \tstruct object_id oid;\n>       -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n>       -\n>       - \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>       - \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n>       --\t\t\t\t\tforce)) {\n>       --\t\t\tret = 1;\n>       -+\t\t\t\t\tforce, warn_only, n_not_merged)) {\n>       -+\t\t\tif (!warn_only)\n>       -+\t\t\t\tret = 1;\n>       - \t\t\tgoto next;\n>       - \t\t}\n>       -\n>        @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n>         \tfree_worktrees(worktrees);\n>         }\n>         \n>       ++struct upstream_pattern {\n>       ++\tchar *name;\n>       ++\tint is_wildcard;\n>       ++};\n>       ++\n>       ++static void upstream_pattern_list_clear(struct upstream_pattern *items,\n>       ++\t\t\t\t\tsize_t nr)\n>       ++{\n>       ++\tsize_t i;\n>       ++\tfor (i = 0; i < nr; i++)\n>       ++\t\tfree(items[i].name);\n>       ++\tfree(items);\n>       ++}\n>       ++\n>       ++static const char *short_upstream_name(const char *full_ref)\n>       ++{\n>       ++\tconst char *short_name = full_ref;\n>       ++\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n>       ++\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n>       ++\treturn short_name;\n>       ++}\n>       ++\n>       ++static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n>       ++{\n>       ++\tstruct ref_store *refs = get_main_ref_store(the_repository);\n>       ++\tstruct remote *remote;\n>       ++\tstruct object_id oid;\n>       ++\tchar *full_ref = NULL;\n>       ++\tstruct strbuf head_ref = STRBUF_INIT;\n>       ++\tconst char *resolved;\n>       ++\n>       ++\tif (has_glob_specials(arg)) {\n>       ++\t\tout->name = xstrdup(arg);\n>       ++\t\tout->is_wildcard = 1;\n>       ++\t\treturn 0;\n>       ++\t}\n>       ++\n>       ++\tremote = remote_get(arg);\n>       ++\tif (remote && remote_is_configured(remote, 0)) {\n>       ++\t\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote->name);\n>       ++\t\tresolved = refs_resolve_ref_unsafe(refs, head_ref.buf,\n>       ++\t\t\t\t\t\t   RESOLVE_REF_NO_RECURSE,\n>       ++\t\t\t\t\t\t   NULL, NULL);\n>       ++\t\tif (resolved && starts_with(resolved, \"refs/remotes/\")) {\n>       ++\t\t\tout->name = xstrdup(short_upstream_name(resolved));\n>       ++\t\t\tout->is_wildcard = 0;\n>       ++\t\t\tstrbuf_release(&head_ref);\n>       ++\t\t\treturn 0;\n>       ++\t\t}\n>       ++\t\tstrbuf_release(&head_ref);\n>       ++\t}\n>       ++\n>       ++\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n>       ++\t\t\t  &full_ref, 0) == 1 &&\n>       ++\t    (starts_with(full_ref, \"refs/heads/\") ||\n>       ++\t     starts_with(full_ref, \"refs/remotes/\"))) {\n>       ++\t\tout->name = xstrdup(short_upstream_name(full_ref));\n>       ++\t\tout->is_wildcard = 0;\n>       ++\t\tfree(full_ref);\n>       ++\t\treturn 0;\n>       ++\t}\n>       ++\tfree(full_ref);\n>       ++\treturn -1;\n>       ++}\n>       ++\n>        +static void parse_forked_args(int argc, const char **argv,\n>       -+\t\t\t      struct string_list *upstream_patterns)\n>       ++\t\t\t      struct upstream_pattern **patterns_out,\n>       ++\t\t\t      size_t *nr_out)\n>        +{\n>       ++\tstruct upstream_pattern *patterns;\n>        +\tint i;\n>        +\n>       ++\tALLOC_ARRAY(patterns, argc);\n>        +\tfor (i = 0; i < argc; i++) {\n>       -+\t\tconst char *arg = argv[i];\n>       -+\t\tstruct object_id oid;\n>       -+\t\tchar *full_ref = NULL;\n>       -+\t\tconst char *short_ref;\n>       -+\n>       -+\t\tif (has_glob_specials(arg)) {\n>       -+\t\t\tstring_list_insert(upstream_patterns, arg);\n>       -+\t\t\tcontinue;\n>       ++\t\tif (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {\n>       ++\t\t\tupstream_pattern_list_clear(patterns, i);\n>       ++\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n>       ++\t\t\t    argv[i]);\n>        +\t\t}\n>       ++\t}\n>       ++\t*patterns_out = patterns;\n>       ++\t*nr_out = argc;\n>       ++}\n>        +\n>       -+\t\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n>       -+\t\t\t\t  &full_ref, 0) == 1 &&\n>       -+\t\t    (skip_prefix(full_ref, \"refs/heads/\", &short_ref) ||\n>       -+\t\t     skip_prefix(full_ref, \"refs/remotes/\", &short_ref))) {\n>       -+\t\t\tstring_list_insert(upstream_patterns, short_ref);\n>       -+\t\t\tfree(full_ref);\n>       -+\t\t\tcontinue;\n>       -+\t\t}\n>       -+\t\tfree(full_ref);\n>       ++static int upstream_matches(const char *short_upstream,\n>       ++\t\t\t    const struct upstream_pattern *patterns,\n>       ++\t\t\t    size_t nr)\n>       ++{\n>       ++\tsize_t i;\n>        +\n>       -+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n>       ++\tfor (i = 0; i < nr; i++) {\n>       ++\t\tconst struct upstream_pattern *p = &patterns[i];\n>       ++\t\tif (p->is_wildcard) {\n>       ++\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n>       ++\t\t\t\treturn 1;\n>       ++\t\t} else if (!strcmp(p->name, short_upstream)) {\n>       ++\t\t\treturn 1;\n>       ++\t\t}\n>        +\t}\n>       ++\treturn 0;\n>        +}\n>        +\n>        +struct forked_cb {\n>       -+\tconst struct string_list *upstream_patterns;\n>       ++\tconst struct upstream_pattern *patterns;\n>       ++\tsize_t nr_patterns;\n>        +\tstruct string_list *out;\n>        +};\n>        +\n>       @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n>        +{\n>        +\tstruct forked_cb *cb = cb_data;\n>        +\tstruct branch *branch;\n>       -+\tconst char *upstream, *short_upstream;\n>       -+\tconst struct string_list_item *item;\n>       ++\tconst char *upstream;\n>        +\n>        +\tif (ref->flags & REF_ISSYMREF)\n>        +\t\treturn 0;\n>       @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n>        +\tupstream = branch_get_upstream(branch, NULL);\n>        +\tif (!upstream)\n>        +\t\treturn 0;\n>       -+\tshort_upstream = upstream;\n>       -+\t(void)(skip_prefix(short_upstream, \"refs/heads/\", &short_upstream) ||\n>       -+\t       skip_prefix(short_upstream, \"refs/remotes/\", &short_upstream));\n>       -+\n>       -+\tfor_each_string_list_item(item, cb->upstream_patterns)\n>       -+\t\tif (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {\n>       -+\t\t\tstring_list_append(cb->out, ref->name)->util =\n>       -+\t\t\t\txstrdup(upstream);\n>       -+\t\t\treturn 0;\n>       -+\t\t}\n>       ++\tif (upstream_matches(short_upstream_name(upstream),\n>       ++\t\t\t     cb->patterns, cb->nr_patterns))\n>       ++\t\tstring_list_append(cb->out, ref->name);\n>        +\treturn 0;\n>        +}\n>        +\n>       -+static void collect_forked_set(int argc, const char **argv,\n>       -+\t\t\t       struct string_list *out)\n>       -+{\n>       -+\tstruct string_list upstream_patterns = STRING_LIST_INIT_DUP;\n>       -+\tstruct forked_cb cb = {\n>       -+\t\t.upstream_patterns = &upstream_patterns,\n>       -+\t\t.out = out,\n>       -+\t};\n>       -+\n>       -+\tparse_forked_args(argc, argv, &upstream_patterns);\n>       -+\n>       -+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n>       -+\t\t\t\t collect_forked_branch, &cb);\n>       -+\n>       -+\tstring_list_clear(&upstream_patterns, 0);\n>       -+}\n>       -+\n>        +static int list_forked_branches(int argc, const char **argv)\n>        +{\n>       ++\tstruct upstream_pattern *patterns = NULL;\n>       ++\tsize_t nr_patterns = 0;\n>        +\tstruct string_list out = STRING_LIST_INIT_DUP;\n>        +\tstruct string_list_item *item;\n>       ++\tstruct forked_cb cb;\n>        +\n>        +\tif (!argc)\n>        +\t\tdie(_(\"--forked requires at least one <branch>\"));\n>        +\n>       -+\tcollect_forked_set(argc, argv, &out);\n>       ++\tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n>       ++\tcb.patterns = patterns;\n>       ++\tcb.nr_patterns = nr_patterns;\n>       ++\tcb.out = &out;\n>       ++\n>       ++\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n>       ++\t\t\t\t collect_forked_branch, &cb);\n>       ++\n>       ++\tstring_list_sort(&out);\n>        +\tfor_each_string_list_item(item, &out)\n>        +\t\tputs(item->string);\n>        +\n>       -+\tstring_list_clear(&out, 1);\n>       ++\tupstream_pattern_list_clear(patterns, nr_patterns);\n>       ++\tstring_list_clear(&out, 0);\n>        +\treturn 0;\n>        +}\n>        +\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\tusage_with_options(builtin_branch_usage, options);\n>         \n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>       - \tif (delete) {\n>       - \t\tif (!argc)\n>         \t\t\tdie(_(\"branch name required\"));\n>       --\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n>       -+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n>       -+\t\t\t\t      quiet, 0, NULL);\n>       -+\t\tgoto out;\n>       + \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n>       + \t\tgoto out;\n>        +\t} else if (forked) {\n>        +\t\tret = list_forked_branches(argc, argv);\n>       - \t\tgoto out;\n>       ++\t\tgoto out;\n>         \t} else if (show_current) {\n>         \t\tprint_current_branch_name();\n>       + \t\tret = 0;\n>        \n>         ## t/t3200-branch.sh ##\n>        @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\tgit clone forked-upstream forked &&\n>        +\tgit -C forked remote add other ../forked-other &&\n>        +\tgit -C forked fetch other &&\n>       ++\tgit -C forked branch local-base &&\n>        +\tgit -C forked branch --track local-one origin/one &&\n>        +\tgit -C forked branch --track local-two origin/two &&\n>        +\tgit -C forked branch --track local-foreign other/foreign &&\n>        +\tgit -C forked branch detached &&\n>       -+\tgit -C forked branch --track topic-on-main main\n>       ++\tgit -C forked branch --track local-trunk local-base\n>        +'\n>        +\n>       -+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '\n>       ++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '\n>        +\tgit -C forked branch --forked origin/one >actual &&\n>        +\techo local-one >expect &&\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       -+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '\n>       -+\tgit -C forked branch --forked main >actual &&\n>       -+\techo topic-on-main >expect &&\n>       -+\ttest_cmp expect actual\n>       -+'\n>       -+\n>       -+test_expect_success '--forked <glob> matches every upstream under the pattern' '\n>       ++test_expect_success '--forked <glob> matches by wildmatch' '\n>        +\tgit -C forked branch --forked \"origin/*\" >actual &&\n>        +\tcat >expect <<-\\EOF &&\n>        +\tlocal-one\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       ++test_expect_success '--forked <local-branch> matches branches with local upstream' '\n>       ++\tgit -C forked branch --forked local-base >actual &&\n>       ++\techo local-trunk >expect &&\n>       ++\ttest_cmp expect actual\n>       ++'\n>       ++\n>       ++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '\n>       ++\ttest_when_finished \"git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main\" &&\n>       ++\tgit -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&\n>       ++\tgit -C forked branch --forked origin >actual &&\n>       ++\techo local-one >expect &&\n>       ++\ttest_cmp expect actual\n>       ++'\n>       ++\n>        +test_expect_success '--forked unions multiple <branch> arguments' '\n>        +\tgit -C forked branch --forked origin/one other/foreign >actual &&\n>        +\tcat >expect <<-\\EOF &&\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +'\n>        +\n>        +test_expect_success '--forked combines literal and glob arguments' '\n>       -+\tgit -C forked branch --forked main \"other/*\" >actual &&\n>       ++\tgit -C forked branch --forked local-base \"other/*\" >actual &&\n>        +\tcat >expect <<-\\EOF &&\n>        +\tlocal-foreign\n>       -+\ttopic-on-main\n>       ++\tlocal-trunk\n>        +\tEOF\n>        +\ttest_cmp expect actual\n>        +'\n>   -:  ---------- > 2:  b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal\n>   -:  ---------- > 3:  6e6580270e branch: prepare delete_branches for a bulk caller\n>   2:  718e28c7e0 ! 4:  e7e03c1338 branch: add --prune-merged <branch>\n>       @@ Commit message\n>        \n>                    git branch --prune-merged <branch>...\n>        \n>       -    deletes the local branches that --forked <branch> would list,\n>       -    but only those whose tip is reachable from their configured\n>       -    upstream: the work has already landed on the upstream the\n>       -    branch tracks, so the local copy is no longer needed.\n>       +    deletes the local branches that \"--forked <branch>\" would list,\n>       +    restricted to those whose tip is reachable from their configured\n>       +    upstream: the work has already landed on the upstream they track,\n>       +    so the local copy is no longer needed.\n>        \n>       -    The following branches are always preserved:\n>       +    Reachability is read from the local refs only -- nothing is\n>       +    fetched. Users who want fresh upstream refs run \"git fetch\" first;\n>       +    the deletion path stays a separate, idempotent step that also\n>       +    works offline.\n>        \n>       -    * the currently checked-out branch in any worktree;\n>       -    * any local branch whose name matches the default branch of\n>       -      any configured remote (the target of\n>       -      refs/remotes/<remote>/HEAD) -- typically 'main' or\n>       -      'master';\n>       -    * any branch whose upstream no longer resolves locally.\n>       +    Three classes of branches are spared:\n>        \n>       -    Reachability is read from whatever branch.<name>.merge\n>       -    resolves to locally, which is usually a remote-tracking ref\n>       -    but may also be a local branch. When the upstream is a\n>       -    remote-tracking ref, the natural workflow is\n>       +      * any branch checked out in any worktree;\n>       +      * any branch whose upstream no longer resolves locally (its\n>       +        disappearance is not, on its own, evidence of integration);\n>       +      * any branch whose push destination equals its upstream\n>       +        (<branch>@{push} == <branch>@{upstream}). Such a branch\n>       +        cannot be distinguished from a freshly pulled trunk that\n>       +        just looks \"fully merged\" -- e.g. local \"main\" tracking and\n>       +        pushing to \"origin/main\" right after a pull. Only branches\n>       +        that push somewhere other than their upstream (typically\n>       +        topics in a fork-based workflow) are treated as candidates.\n>        \n>       -            git fetch <remote>\n>       -            git branch --prune-merged <upstream-pattern>\n>       -\n>       -    so the upstream reflects the current state before pruning.\n>       +    Deletion goes through the existing delete_branches() in warn-only\n>       +    mode and with the HEAD-fallback disabled: a branch that is not\n>       +    yet fully merged to its upstream is reported as a one-line warning\n>       +    and skipped, so a single un-mergeable topic does not abort the\n>       +    whole sweep, and there is no fallback to \"merged into the\n>       +    currently checked out branch\" -- we only act on upstream-merged\n>       +    status.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n>       - \ta ref (e.g. `origin/master`, `master`) or a shell-style\n>       - \tglob (e.g. `'origin/*'`). Multiple arguments are unioned.\n>       +@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n>       + to match the way `git checkout -b topic origin` picks a starting\n>       + point.  Multiple _<branch>_ arguments are unioned.\n>         \n>        +`--prune-merged`::\n>       -+\tDelete the local branches that `--forked` would list for\n>       -+\tthe same _<branch>_ arguments, but only those whose tip is\n>       -+\treachable from their configured upstream.\n>       ++\tDelete the local branches that `--forked` would list for the\n>       ++\tsame _<branch>_ arguments, but only those whose tip is\n>       ++\treachable from their configured upstream.  In other words,\n>       ++\tthe work on the branch has already landed on the upstream it\n>       ++\ttracks, so the local copy is no longer needed.\n>        ++\n>       -+For arguments that refer to remote-tracking branches, run\n>       -+`git fetch` first so reachability is checked against the\n>       -+current upstream state; refs are read locally.\n>       ++Reachability is checked against whatever the upstream refs say\n>       ++locally; nothing is fetched.  Run `git fetch` first if you want\n>       ++the upstream refs refreshed.\n>        ++\n>       -+The following branches are always preserved:\n>       ++A branch is left alone if any of the following holds:\n>       ++its upstream no longer resolves locally; it is checked out in any\n>       ++worktree; or its push destination (`<branch>@{push}`) equals its\n>       ++upstream (`<branch>@{upstream}`), so it cannot be distinguished\n>       ++from a freshly pulled trunk that just looks \"fully merged\".\n>        ++\n>       -+--\n>       -+* the currently checked-out branch in any worktree;\n>       -+* any local branch whose name matches the default branch of\n>       -+  any configured remote (the target of\n>       -+  `refs/remotes/<remote>/HEAD`) -- typically `main` or\n>       -+  `master`;\n>       -+* any branch whose upstream no longer resolves locally.\n>       -+--\n>       ++Branches refused by the \"fully merged\" safety check are listed as\n>       ++warnings and skipped; pass them to `git branch -D` explicitly if\n>       ++you want them gone.\n>        +\n>         `-v`::\n>         `-vv`::\n>         `--verbose`::\n>        \n>         ## builtin/branch.c ##\n>       -@@\n>       - #include \"branch.h\"\n>       - #include \"path.h\"\n>       - #include \"string-list.h\"\n>       -+#include \"strvec.h\"\n>       - #include \"column.h\"\n>       - #include \"utf8.h\"\n>       - #include \"ref-filter.h\"\n>       -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n>       - \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>       - \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n>       - \tN_(\"git branch [<options>] --forked <branch>...\"),\n>       -+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n>       - \tNULL\n>       - };\n>       -\n>       -@@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n>       - \t * any of the following code, but during the transition period,\n>       - \t * a gentle reminder is in order.\n>       - \t */\n>       --\tif (head_rev != reference_rev) {\n>       --\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n>       -+\tif (head_rev && head_rev != reference_rev) {\n>       -+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n>       - \t\tif (expect < 0)\n>       - \t\t\texit(128);\n>       - \t\tif (expect == merged)\n>        @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n>         \treturn 0;\n>         }\n>         \n>       -+static int collect_default_branch_name(struct remote *remote, void *cb_data)\n>       -+{\n>       -+\tstruct string_list *protected = cb_data;\n>       -+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n>       -+\tstruct strbuf head = STRBUF_INIT;\n>       -+\tconst char *target;\n>       -+\n>       -+\tstrbuf_addf(&head, \"refs/remotes/%s/HEAD\", remote->name);\n>       -+\ttarget = refs_resolve_ref_unsafe(refs, head.buf,\n>       -+\t\t\t\t\t RESOLVE_REF_NO_RECURSE, NULL, NULL);\n>       -+\tif (target) {\n>       -+\t\tconst char *leaf = strrchr(target, '/');\n>       -+\t\tif (leaf)\n>       -+\t\t\tstring_list_insert(protected, leaf + 1);\n>       -+\t}\n>       -+\tstrbuf_release(&head);\n>       -+\treturn 0;\n>       +-static int list_forked_branches(int argc, const char **argv)\n>       ++static void collect_forked_set(int argc, const char **argv,\n>       ++\t\t\t       struct string_list *out)\n>       + {\n>       + \tstruct upstream_pattern *patterns = NULL;\n>       + \tsize_t nr_patterns = 0;\n>       +-\tstruct string_list out = STRING_LIST_INIT_DUP;\n>       +-\tstruct string_list_item *item;\n>       + \tstruct forked_cb cb;\n>       +\n>       +-\tif (!argc)\n>       +-\t\tdie(_(\"--forked requires at least one <branch>\"));\n>       +-\n>       + \tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n>       + \tcb.patterns = patterns;\n>       + \tcb.nr_patterns = nr_patterns;\n>       +-\tcb.out = &out;\n>       ++\tcb.out = out;\n>       +\n>       + \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n>       + \t\t\t\t collect_forked_branch, &cb);\n>       +\n>       +-\tstring_list_sort(&out);\n>       ++\tstring_list_sort(out);\n>       ++\n>       ++\tupstream_pattern_list_clear(patterns, nr_patterns);\n>        +}\n>        +\n>       - static void collect_forked_set(int argc, const char **argv,\n>       - \t\t\t       struct string_list *out)\n>       - {\n>       -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n>       ++static int list_forked_branches(int argc, const char **argv)\n>       ++{\n>       ++\tstruct string_list out = STRING_LIST_INIT_DUP;\n>       ++\tstruct string_list_item *item;\n>       ++\n>       ++\tif (!argc)\n>       ++\t\tdie(_(\"--forked requires at least one <branch>\"));\n>       ++\n>       ++\tcollect_forked_set(argc, argv, &out);\n>       + \tfor_each_string_list_item(item, &out)\n>       + \t\tputs(item->string);\n>       +\n>       +-\tupstream_pattern_list_clear(patterns, nr_patterns);\n>       + \tstring_list_clear(&out, 0);\n>         \treturn 0;\n>         }\n>         \n>       @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n>        +{\n>        +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n>        +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n>       -+\tstruct string_list protected_default_names = STRING_LIST_INIT_DUP;\n>        +\tstruct strvec deletable = STRVEC_INIT;\n>       -+\tstruct strbuf buf = STRBUF_INIT;\n>        +\tstruct string_list_item *item;\n>       -+\tint n_not_merged = 0;\n>        +\tint ret = 0;\n>        +\n>        +\tif (!argc)\n>        +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n>        +\n>        +\tcollect_forked_set(argc, argv, &candidates);\n>       -+\tfor_each_remote(collect_default_branch_name, &protected_default_names);\n>        +\n>        +\tfor_each_string_list_item(item, &candidates) {\n>        +\t\tconst char *short_name = item->string;\n>       -+\t\tconst char *upstream = item->util;\n>       -+\n>       -+\t\tstrbuf_reset(&buf);\n>       -+\t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n>       -+\t\tif (branch_checked_out(buf.buf))\n>       ++\t\tstruct branch *branch = branch_get(short_name);\n>       ++\t\tconst char *upstream, *push;\n>       ++\t\tstruct strbuf full = STRBUF_INIT;\n>       ++\t\tint skip;\n>       ++\n>       ++\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n>       ++\t\tskip = !!branch_checked_out(full.buf);\n>       ++\t\tstrbuf_release(&full);\n>       ++\t\tif (skip)\n>        +\t\t\tcontinue;\n>        +\n>       -+\t\tif (string_list_has_string(&protected_default_names,\n>       -+\t\t\t\t\t   short_name))\n>       ++\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n>       ++\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n>        +\t\t\tcontinue;\n>       -+\n>       -+\t\tif (!refs_ref_exists(refs, upstream))\n>       ++\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n>       ++\t\tif (!push || !strcmp(push, upstream))\n>        +\t\t\tcontinue;\n>        +\n>        +\t\tstrvec_push(&deletable, short_name);\n>        +\t}\n>       -+\tstrbuf_release(&buf);\n>        +\n>        +\tif (deletable.nr)\n>        +\t\tret = delete_branches(deletable.nr, deletable.v,\n>       -+\t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n>       -+\t\t\t\t      1, &n_not_merged);\n>       -+\n>       -+\tif (n_not_merged && !quiet)\n>       -+\t\tfprintf(stderr,\n>       -+\t\t\tQ_(\"Skipped %d branch that is not fully merged; \"\n>       -+\t\t\t   \"delete it with 'git branch -D' if you are sure.\\n\",\n>       -+\t\t\t   \"Skipped %d branches that are not fully merged; \"\n>       -+\t\t\t   \"delete them with 'git branch -D' if you are sure.\\n\",\n>       -+\t\t\t   n_not_merged),\n>       -+\t\t\tn_not_merged);\n>       ++\t\t\t\t      0, /* force */\n>       ++\t\t\t\t      FILTER_REFS_BRANCHES,\n>       ++\t\t\t\t      quiet,\n>       ++\t\t\t\t      1, /* warn_only */\n>       ++\t\t\t\t      1, /* no_head_fallback */\n>       ++\t\t\t\t      0  /* dry_run */);\n>        +\n>        +\tstrvec_clear(&deletable);\n>       -+\tstring_list_clear(&candidates, 1);\n>       -+\tstring_list_clear(&protected_default_names, 0);\n>       ++\tstring_list_clear(&candidates, 0);\n>        +\treturn ret;\n>        +}\n>        +\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\tOPT_BOOL(0, \"forked\", &forked,\n>         \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n>        +\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n>       -+\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n>       ++\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n>         \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>         \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>         \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +\tgit -C pm-upstream branch one HEAD~ &&\n>        +\tgit -C pm-upstream branch two HEAD &&\n>        +\tgit -C pm-upstream branch wip main &&\n>       -+\tgit -C pm-upstream checkout main\n>       ++\tgit -C pm-upstream checkout main &&\n>       ++\ttest_create_repo pm-fork\n>        +'\n>        +\n>        +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n>        +\ttest_when_finished \"rm -rf pm-merged\" &&\n>        +\tgit clone pm-upstream pm-merged &&\n>       ++\tgit -C pm-merged remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-merged remote.pushDefault fork &&\n>       ++\ttest_config -C pm-merged push.default current &&\n>        +\tgit -C pm-merged branch one one-commit &&\n>        +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n>        +\tgit -C pm-merged branch two two-commit &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged with a literal upstream argument' '\n>       ++test_expect_success '--prune-merged accepts a literal upstream' '\n>        +\ttest_when_finished \"rm -rf pm-literal\" &&\n>        +\tgit clone pm-upstream pm-literal &&\n>       ++\tgit -C pm-literal remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-literal remote.pushDefault fork &&\n>       ++\ttest_config -C pm-literal push.default current &&\n>        +\tgit -C pm-literal branch one one-commit &&\n>        +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n>       -+\tgit -C pm-literal branch keepme one-commit &&\n>       -+\tgit -C pm-literal branch --set-upstream-to=origin/main keepme &&\n>        +\n>        +\tgit -C pm-literal branch --prune-merged origin/next &&\n>        +\n>       -+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&\n>       -+\tgit -C pm-literal rev-parse --verify refs/heads/keepme\n>       ++\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n>        +'\n>        +\n>        +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n>        +\ttest_when_finished \"rm -rf pm-union\" &&\n>        +\tgit clone pm-upstream pm-union &&\n>       ++\tgit -C pm-union remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-union remote.pushDefault fork &&\n>       ++\ttest_config -C pm-union push.default current &&\n>        +\tgit -C pm-union branch one one-commit &&\n>        +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n>        +\tgit -C pm-union branch two base &&\n>        +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n>       ++\tgit -C pm-union checkout --detach &&\n>        +\n>        +\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged with a local-branch argument' '\n>       -+\ttest_create_repo pm-local &&\n>       ++test_expect_success '--prune-merged accepts a local upstream' '\n>        +\ttest_when_finished \"rm -rf pm-local\" &&\n>       -+\ttest_commit -C pm-local base &&\n>       -+\tgit -C pm-local branch topic base &&\n>       -+\tgit -C pm-local config branch.topic.remote . &&\n>       -+\tgit -C pm-local config branch.topic.merge refs/heads/main &&\n>       -+\tgit -C pm-local checkout --detach &&\n>       -+\n>       -+\tgit -C pm-local branch --prune-merged main &&\n>       -+\n>       -+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&\n>       -+\tgit -C pm-local rev-parse --verify refs/heads/main\n>       ++\tgit clone pm-upstream pm-local &&\n>       ++\tgit -C pm-local remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-local remote.pushDefault fork &&\n>       ++\ttest_config -C pm-local push.default current &&\n>       ++\tgit -C pm-local checkout -b trunk &&\n>       ++\tgit -C pm-local branch one one-commit &&\n>       ++\tgit -C pm-local branch --set-upstream-to=trunk one &&\n>       ++\tgit -C pm-local merge --ff-only one-commit &&\n>       ++\n>       ++\tgit -C pm-local branch --prune-merged trunk &&\n>       ++\n>       ++\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged spares branches with un-integrated commits' '\n>       ++test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n>        +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n>        +\tgit clone pm-upstream pm-unmerged &&\n>       ++\tgit -C pm-unmerged remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-unmerged remote.pushDefault fork &&\n>       ++\ttest_config -C pm-unmerged push.default current &&\n>        +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n>        +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n>        +\ttest_commit -C pm-unmerged local-only &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +\n>        +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n>        +\ttest_grep \"not fully merged\" err &&\n>       -+\ttest_grep \"Skipped 1 branch\" err &&\n>       -+\ttest_grep \"git branch -D\" err &&\n>        +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n>        +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n>        +'\n>        +\n>       ++test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n>       ++\ttest_when_finished \"rm -rf pm-nohead\" &&\n>       ++\tgit clone pm-upstream pm-nohead &&\n>       ++\tgit -C pm-nohead remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-nohead remote.pushDefault fork &&\n>       ++\ttest_config -C pm-nohead push.default current &&\n>       ++\tgit -C pm-nohead branch topic one-commit &&\n>       ++\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n>       ++\n>       ++\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n>       ++\n>       ++\ttest_grep ! \"not yet merged to HEAD\" err &&\n>       ++\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n>       ++'\n>       ++\n>        +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n>        +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n>        +\tgit clone pm-upstream pm-upstream-gone &&\n>       ++\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n>       ++\ttest_config -C pm-upstream-gone push.default current &&\n>        +\tgit -C pm-upstream-gone branch one one-commit &&\n>        +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +test_expect_success '--prune-merged never deletes the checked-out branch' '\n>        +\ttest_when_finished \"rm -rf pm-head\" &&\n>        +\tgit clone pm-upstream pm-head &&\n>       ++\tgit -C pm-head remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-head remote.pushDefault fork &&\n>       ++\ttest_config -C pm-head push.default current &&\n>        +\tgit -C pm-head checkout -b one one-commit &&\n>        +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n>        +\tgit -C pm-head rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged spares the local default branch' '\n>       -+\ttest_when_finished \"rm -rf pm-default\" &&\n>       -+\tgit clone pm-upstream pm-default &&\n>       -+\tgit -C pm-default checkout --detach &&\n>       -+\tgit -C pm-default branch --prune-merged \"origin/*\" &&\n>       -+\tgit -C pm-default rev-parse --verify refs/heads/main\n>       ++test_expect_success '--prune-merged spares branches that push back to their upstream' '\n>       ++\ttest_when_finished \"rm -rf pm-push-eq\" &&\n>       ++\tgit clone pm-upstream pm-push-eq &&\n>       ++\tgit -C pm-push-eq checkout --detach &&\n>       ++\n>       ++\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n>       ++\n>       ++\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged protects the default branch by name only' '\n>       -+\ttest_when_finished \"rm -rf pm-default-alias\" &&\n>       -+\tgit clone pm-upstream pm-default-alias &&\n>       -+\tgit -C pm-default-alias branch --track trunk origin/main &&\n>       -+\tgit -C pm-default-alias checkout --detach &&\n>       -+\tgit -C pm-default-alias branch --prune-merged \"origin/*\" &&\n>       -+\tgit -C pm-default-alias rev-parse --verify refs/heads/main &&\n>       -+\ttest_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk\n>       ++test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n>       ++\ttest_when_finished \"rm -rf pm-push-branch\" &&\n>       ++\tgit clone pm-upstream pm-push-branch &&\n>       ++\tgit -C pm-push-branch remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-push-branch remote.pushDefault fork &&\n>       ++\ttest_config -C pm-push-branch push.default current &&\n>       ++\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n>       ++\tgit -C pm-push-branch checkout --detach &&\n>       ++\n>       ++\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n>       ++\n>       ++\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged with literal arg also protects default-name' '\n>       -+\ttest_when_finished \"rm -rf pm-literal-default\" &&\n>       -+\tgit clone pm-upstream pm-literal-default &&\n>       -+\tgit -C pm-literal-default checkout --detach &&\n>       -+\tgit -C pm-literal-default branch --prune-merged origin/main &&\n>       -+\tgit -C pm-literal-default rev-parse --verify refs/heads/main\n>       ++test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n>       ++\ttest_when_finished \"rm -rf pm-push-diff\" &&\n>       ++\tgit clone pm-upstream pm-push-diff &&\n>       ++\tgit -C pm-push-diff remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-push-diff remote.pushDefault fork &&\n>       ++\ttest_config -C pm-push-diff push.default current &&\n>       ++\tgit -C pm-push-diff branch topic one-commit &&\n>       ++\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n>       ++\tgit -C pm-push-diff checkout --detach &&\n>       ++\n>       ++\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n>       ++\n>       ++\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n>        +'\n>        +\n>        +test_expect_success '--prune-merged requires at least one <branch>' '\n>       -+\ttest_must_fail git -C pm-upstream branch --prune-merged 2>err &&\n>       ++\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n>        +\ttest_grep \"at least one <branch>\" err\n>        +'\n>        +\n>   3:  6e38d7af3a ! 5:  75b6d2366a branch: add branch.<name>.pruneMerged opt-out\n>       @@ Metadata\n>         ## Commit message ##\n>            branch: add branch.<name>.pruneMerged opt-out\n>        \n>       -    Setting branch.<name>.pruneMerged=false exempts that branch\n>       -    from --prune-merged. Useful for topic branches you intend to\n>       -    develop further after an initial round has been merged\n>       +    Setting branch.<name>.pruneMerged=false exempts that branch from\n>       +    \"git branch --prune-merged\". Useful for a topic branch you want\n>       +    to develop further after an initial round has been merged\n>            upstream.\n>        \n>       -    Explicit deletion via 'git branch -d' is unaffected.\n>       +    Unless --quiet is given, the skip is reported per branch so the\n>       +    user knows why their topic was preserved.\n>       +\n>       +    Explicit deletion via \"git branch -d\" continues to consult the\n>       +    normal merge check and is not affected by this setting.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/config/branch.adoc: for details).\n>        +\n>        +`branch.<name>.pruneMerged`::\n>        +\tIf set to `false`, branch _<name>_ is exempt from\n>       -+\t`git branch --prune-merged`. Defaults to true. Explicit\n>       -+\tdeletion via `git branch -d` is unaffected.\n>       ++\t`git branch --prune-merged`.  Useful for a topic branch you\n>       ++\tintend to develop further after an initial round has been\n>       ++\tmerged upstream.  Defaults to true.  Explicit deletion via\n>       ++\t`git branch -d` is unaffected.\n>        \n>         ## Documentation/git-branch.adoc ##\n>       -@@ Documentation/git-branch.adoc: The following branches are always preserved:\n>       -   any configured remote (the target of\n>       -   `refs/remotes/<remote>/HEAD`) -- typically `main` or\n>       -   `master`;\n>       -+* any branch with `branch.<name>.pruneMerged` set to `false`;\n>       - * any branch whose upstream no longer resolves locally.\n>       - --\n>       -\n>       +@@ Documentation/git-branch.adoc: the upstream refs refreshed.\n>       + +\n>       + A branch is left alone if any of the following holds:\n>       + its upstream no longer resolves locally; it is checked out in any\n>       +-worktree; or its push destination (`<branch>@{push}`) equals its\n>       ++worktree; its push destination (`<branch>@{push}`) equals its\n>       + upstream (`<branch>@{upstream}`), so it cannot be distinguished\n>       +-from a freshly pulled trunk that just looks \"fully merged\".\n>       ++from a freshly pulled trunk that just looks \"fully merged\"; or\n>       ++`branch.<name>.pruneMerged` is set to `false`.\n>       + +\n>       + Branches refused by the \"fully merged\" safety check are listed as\n>       + warnings and skipped; pass them to `git branch -D` explicitly if\n>        \n>         ## builtin/branch.c ##\n>        @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n>       - \tfor_each_string_list_item(item, &candidates) {\n>       - \t\tconst char *short_name = item->string;\n>       - \t\tconst char *upstream = item->util;\n>       -+\t\tint prune_allowed = 1;\n>       + \t\tstruct branch *branch = branch_get(short_name);\n>       + \t\tconst char *upstream, *push;\n>       + \t\tstruct strbuf full = STRBUF_INIT;\n>       ++\t\tstruct strbuf key = STRBUF_INIT;\n>       + \t\tint skip;\n>       ++\t\tint opt_out;\n>         \n>       - \t\tstrbuf_reset(&buf);\n>       - \t\tstrbuf_addf(&buf, \"refs/heads/%s\", short_name);\n>       + \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n>       + \t\tskip = !!branch_checked_out(full.buf);\n>        @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n>       - \t\tif (!refs_ref_exists(refs, upstream))\n>       + \t\tif (!push || !strcmp(push, upstream))\n>         \t\t\tcontinue;\n>         \n>       -+\t\tstrbuf_reset(&buf);\n>       -+\t\tstrbuf_addf(&buf, \"branch.%s.prunemerged\", short_name);\n>       -+\t\tif (!repo_config_get_bool(the_repository, buf.buf,\n>       -+\t\t\t\t\t  &prune_allowed) &&\n>       -+\t\t    !prune_allowed) {\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n>       ++\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n>       ++\t\t    !opt_out) {\n>        +\t\t\tif (!quiet)\n>       -+\t\t\t\tfprintf(stderr, _(\"Skipping '%s' \"\n>       -+\t\t\t\t\t\t  \"(branch.%s.pruneMerged is false)\\n\"),\n>       ++\t\t\t\tfprintf(stderr,\n>       ++\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n>        +\t\t\t\t\tshort_name, short_name);\n>       ++\t\t\tstrbuf_release(&key);\n>        +\t\t\tcontinue;\n>        +\t\t}\n>       ++\t\tstrbuf_release(&key);\n>        +\n>         \t\tstrvec_push(&deletable, short_name);\n>         \t}\n>       - \tstrbuf_release(&buf);\n>       +\n>        \n>         ## t/t3200-branch.sh ##\n>        @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '\n>       @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br\n>        +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n>        +\ttest_when_finished \"rm -rf pm-optout\" &&\n>        +\tgit clone pm-upstream pm-optout &&\n>       ++\tgit -C pm-optout remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-optout remote.pushDefault fork &&\n>       ++\ttest_config -C pm-optout push.default current &&\n>        +\tgit -C pm-optout branch one one-commit &&\n>        +\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n>        +\tgit -C pm-optout branch two two-commit &&\n>        +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n>       -+\tgit -C pm-optout config branch.one.pruneMerged false &&\n>       ++\ttest_config -C pm-optout branch.one.pruneMerged false &&\n>        +\n>        +\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br\n>        +\tgit clone pm-upstream pm-optout-d &&\n>        +\tgit -C pm-optout-d branch one one-commit &&\n>        +\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n>       -+\tgit -C pm-optout-d config branch.one.pruneMerged false &&\n>       ++\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n>        +\n>        +\tgit -C pm-optout-d branch -d one &&\n>        +\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>   4:  c68d162e22 ! 6:  a1a42a6b19 branch: add --dry-run for --prune-merged\n>       @@ Metadata\n>         ## Commit message ##\n>            branch: add --dry-run for --prune-merged\n>        \n>       -    With --dry-run, --prune-merged prints the branches it would\n>       -    delete and exits without touching any ref. Useful for\n>       -    sanity-checking a glob like 'origin/*' before letting it run.\n>       +    With --dry-run, --prune-merged prints the local branches it would\n>       +    delete -- one \"Would delete branch <name>\" line per candidate --\n>       +    and exits without touching any ref.\n>       +\n>       +    This is the natural sanity check before letting a broad pattern\n>       +    like 'origin/*' run for real: the @{push}-vs-@{upstream} and\n>       +    unmerged filtering still applies, so the dry-run output is\n>       +    exactly the set that the live run would delete.\n>       +\n>       +    --dry-run is only meaningful in combination with --prune-merged\n>       +    and is rejected otherwise.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: The following branches are always preserved:\n>       - * any branch whose upstream no longer resolves locally.\n>       - --\n>       +@@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety check are listed as\n>       + warnings and skipped; pass them to `git branch -D` explicitly if\n>       + you want them gone.\n>         \n>        +`--dry-run`::\n>       -+\tWith `--prune-merged`, print the branches that would be\n>       -+\tdeleted instead of deleting them.\n>       ++\tWith `--prune-merged`, print which branches would be\n>       ++\tdeleted and exit without touching any ref.  Useful for\n>       ++\tsanity-checking a wide pattern like `'origin/*'` before\n>       ++\tcommitting to the deletion.\n>        +\n>         `-v`::\n>         `-vv`::\n>         `--verbose`::\n>        \n>         ## builtin/branch.c ##\n>       -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n>       - \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>       - \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n>       - \tN_(\"git branch [<options>] --forked <branch>...\"),\n>       --\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n>       -+\tN_(\"git branch [<options>] --prune-merged [--dry-run] <branch>...\"),\n>       - \tNULL\n>       - };\n>       -\n>       -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n>       - }\n>       -\n>       - static int delete_branches(int argc, const char **argv, int force, int kinds,\n>       --\t\t\t   int quiet, int warn_only, int *n_not_merged)\n>       -+\t\t\t   int quiet, int warn_only, int dry_run,\n>       -+\t\t\t   int *n_not_merged)\n>       - {\n>       - \tstruct commit *head_rev = NULL;\n>       - \tstruct object_id oid;\n>       -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n>       - \t\t\tgoto next;\n>       - \t\t}\n>       -\n>       -+\t\tif (dry_run) {\n>       -+\t\t\tprintf(_(\"Would delete branch '%s'\\n\"),\n>       -+\t\t\t       name + branch_name_pos);\n>       -+\t\t\tgoto next;\n>       -+\t\t}\n>       -+\n>       - \t\titem = string_list_append(&refs_to_delete, name);\n>       - \t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n>       - \t\t\t\t    : (flags & REF_ISSYMREF) ? target\n>        @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n>         \treturn 0;\n>         }\n>         \n>        -static int prune_merged_branches(int argc, const char **argv, int quiet)\n>       -+static int prune_merged_branches(int argc, const char **argv,\n>       -+\t\t\t\t int dry_run, int quiet)\n>       ++static int prune_merged_branches(int argc, const char **argv, int quiet,\n>       ++\t\t\t\t int dry_run)\n>         {\n>         \tstruct ref_store *refs = get_main_ref_store(the_repository);\n>         \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n>        @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n>       - \tif (deletable.nr)\n>       - \t\tret = delete_branches(deletable.nr, deletable.v,\n>       - \t\t\t\t      0, FILTER_REFS_BRANCHES, quiet,\n>       --\t\t\t\t      1, &n_not_merged);\n>       -+\t\t\t\t      1, dry_run, &n_not_merged);\n>       + \t\t\t\t      quiet,\n>       + \t\t\t\t      1, /* warn_only */\n>       + \t\t\t\t      1, /* no_head_fallback */\n>       +-\t\t\t\t      0  /* dry_run */);\n>       ++\t\t\t\t      dry_run);\n>         \n>       - \tif (n_not_merged && !quiet)\n>       - \t\tfprintf(stderr,\n>       + \tstrvec_clear(&deletable);\n>       + \tstring_list_clear(&candidates, 0);\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>         \tint forked = 0;\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n>         \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n>       - \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and that are merged into it\")),\n>       + \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n>        +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n>       -+\t\t\tN_(\"with --prune-merged, only print what would be deleted\")),\n>       ++\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n>         \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>         \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>         \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>       - \targc = parse_options(argc, argv, prefix, options, builtin_branch_usage,\n>       - \t\t\t     0);\n>       + \tif (noncreate_actions > 1)\n>       + \t\tusage_with_options(builtin_branch_usage, options);\n>         \n>        +\tif (dry_run && !prune_merged)\n>        +\t\tdie(_(\"--dry-run requires --prune-merged\"));\n>        +\n>       - \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n>       - \t    !show_current && !unset_upstream && !forked && !prune_merged &&\n>       - \t    argc == 0)\n>       + \tif (recurse_submodules_explicit) {\n>       + \t\tif (!submodule_propagate_branches)\n>       + \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>       - \t\tif (!argc)\n>       - \t\t\tdie(_(\"branch name required\"));\n>       - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n>       --\t\t\t\t      quiet, 0, NULL);\n>       -+\t\t\t\t      quiet, 0, 0, NULL);\n>       - \t\tgoto out;\n>       - \t} else if (forked) {\n>         \t\tret = list_forked_branches(argc, argv);\n>         \t\tgoto out;\n>         \t} else if (prune_merged) {\n>        -\t\tret = prune_merged_branches(argc, argv, quiet);\n>       -+\t\tret = prune_merged_branches(argc, argv, dry_run, quiet);\n>       ++\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n>         \t\tgoto out;\n>         \t} else if (show_current) {\n>         \t\tprint_current_branch_name();\n>       @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n>         \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>         '\n>         \n>       -+test_expect_success '--prune-merged --dry-run prints but does not delete' '\n>       -+\ttest_when_finished \"rm -rf pm-dryrun\" &&\n>       -+\tgit clone pm-upstream pm-dryrun &&\n>       -+\tgit -C pm-dryrun branch one one-commit &&\n>       -+\tgit -C pm-dryrun branch --set-upstream-to=origin/next one &&\n>       ++test_expect_success '--prune-merged --dry-run lists but does not delete' '\n>       ++\ttest_when_finished \"rm -rf pm-dry\" &&\n>       ++\tgit clone pm-upstream pm-dry &&\n>       ++\tgit -C pm-dry remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-dry remote.pushDefault fork &&\n>       ++\ttest_config -C pm-dry push.default current &&\n>       ++\tgit -C pm-dry branch one one-commit &&\n>       ++\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n>       ++\tgit -C pm-dry branch two two-commit &&\n>       ++\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n>       ++\n>       ++\tgit -C pm-dry branch --prune-merged --dry-run \"origin/*\" >actual &&\n>       ++\ttest_grep \"Would delete branch one \" actual &&\n>       ++\ttest_grep \"Would delete branch two \" actual &&\n>        +\n>       -+\tgit -C pm-dryrun branch --prune-merged --dry-run \"origin/*\" >out &&\n>       -+\ttest_grep \"Would delete branch .one.\" out &&\n>       -+\tgit -C pm-dryrun rev-parse --verify refs/heads/one\n>       ++\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n>       ++\tgit -C pm-dry rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '\n>       -+\ttest_when_finished \"rm -rf pm-dryrun-unmerged\" &&\n>       -+\tgit clone pm-upstream pm-dryrun-unmerged &&\n>       -+\tgit -C pm-dryrun-unmerged checkout -b wip origin/next &&\n>       -+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&\n>       -+\ttest_commit -C pm-dryrun-unmerged local-only &&\n>       -+\tgit -C pm-dryrun-unmerged checkout - &&\n>       -+\tgit -C pm-dryrun-unmerged branch merged one-commit &&\n>       -+\tgit -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&\n>       ++test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n>       ++\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n>       ++\tgit clone pm-upstream pm-dry-mixed &&\n>       ++\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n>       ++\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n>       ++\ttest_config -C pm-dry-mixed push.default current &&\n>       ++\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n>       ++\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n>       ++\ttest_commit -C pm-dry-mixed local-only &&\n>       ++\tgit -C pm-dry-mixed checkout - &&\n>       ++\tgit -C pm-dry-mixed branch merged one-commit &&\n>       ++\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n>        +\n>       -+\tgit -C pm-dryrun-unmerged branch --prune-merged --dry-run \"origin/*\" \\\n>       -+\t\t>out 2>err &&\n>       -+\ttest_grep \"Would delete branch .merged.\" out &&\n>       -+\ttest_grep ! \"Would delete branch .wip.\" out &&\n>       -+\ttest_grep \"not fully merged\" err &&\n>       -+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&\n>       -+\tgit -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged\n>       ++\tgit -C pm-dry-mixed branch --prune-merged --dry-run \"origin/*\" >out &&\n>       ++\ttest_grep \"Would delete branch merged\" out &&\n>       ++\ttest_grep ! \"Would delete branch wip\" out &&\n>       ++\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n>       ++\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n>        +'\n>        +\n>       -+test_expect_success '--dry-run requires --prune-merged' '\n>       -+\ttest_must_fail git -C pm-upstream branch --dry-run 2>err &&\n>       ++test_expect_success '--dry-run without --prune-merged is rejected' '\n>       ++\ttest_must_fail git -C forked branch --dry-run 2>err &&\n>        +\ttest_grep \"requires --prune-merged\" err\n>        +'\n>        +\n> \n\n"},{"id":"544512","messageId":"CAHwyqnVL=PSCpH2s5RS5kzEo9FXmmjc0n4Oc_ztg3=uhP+-Z8w@mail.gmail.com","threadId":"65574","inReplyTo":"9b44d867-219a-4ca3-b8ae-67fdac1c72f6@gmail.com","subject":"Re: [PATCH v11 0/6] branch: prune-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-02T13:41:23Z","receivedAt":"2026-06-02T13:42:01Z","isPatch":true,"body":"> Hi Harald\n>\n> Just a quick note to say I've not forgotten about this, hopefully I\n> should have time to review it later in the week now I'm back on the list.\n>\n> Thanks\n>\n> Phillip\n\nGreat to hear! Thanks!\n\n\nHarald\n"},{"id":"544586","messageId":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com","subject":"[PATCH v12 0/6] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:33Z","receivedAt":"2026-06-03T09:04:43Z","isPatch":true,"body":" * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nHarald Nordgren (6):\n  branch: add --forked filter for --list mode\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --prune-merged <branch>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --dry-run for --prune-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  37 ++++\n builtin/branch.c                 | 317 +++++++++++++++++++++++++--\n ref-filter.c                     |  10 +-\n ref-filter.h                     |   2 +\n t/t3200-branch.sh                | 354 +++++++++++++++++++++++++++++++\n 6 files changed, 701 insertions(+), 26 deletions(-)\n\n\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v12\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v12\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v11:\n\n 1:  b9fddd124a ! 1:  8834c424fb branch: add --forked <branch>\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --forked <branch>\n     +    branch: add --forked filter for --list mode\n      \n     -    List local branches whose configured upstream\n     -    (branch.<name>.merge resolved against branch.<name>.remote)\n     -    matches any of the given <branch> arguments.\n     +    Add a --forked option to \"git branch\" list mode that keeps only\n     +    branches whose configured upstream matches <branch>. The argument\n     +    can be a ref (e.g. \"origin/main\", \"master\") or a shell-style\n     +    glob (e.g. \"origin/*\"). The option can be repeated to widen the\n     +    filter.\n      \n     -    Each <branch> is interpreted against the local repository, not\n     -    against any specific remote:\n     +    Because it is a filter on list mode, --forked composes with the\n     +    existing list-mode filters, so\n      \n     -      * a literal upstream short name, e.g. \"origin/main\" or \"master\"\n     -        for a branch whose upstream is local;\n     -      * a wildmatch pattern, e.g. \"origin/*\";\n     -      * a bare configured-remote name, e.g. \"origin\", which resolves\n     -        to whatever refs/remotes/origin/HEAD points at, matching how\n     -        \"git checkout -b topic origin\" picks a starting point.\n     +        git branch --merged origin/main --forked 'origin/*'\n      \n     -    The literal-vs-wildcard distinction is settled at parse time so\n     -    the per-branch matching loop calls wildmatch() only for genuine\n     -    wildcards. Multiple <branch> arguments are unioned. Output is\n     -    sorted by branch name.\n     +    lists branches forked from origin that have already been\n     +    integrated into origin/main, and --no-merged inverts the question.\n      \n          This is the building block for --prune-merged, which deletes the\n          listed branches once they have landed on their upstream.\n     @@ Commit message\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n     - git branch (-c|-C) [<old-branch>] <new-branch>\n     - git branch (-d|-D) [-r] <branch-name>...\n     - git branch --edit-description [<branch-name>]\n     -+git branch --forked <branch>...\n     - \n     - DESCRIPTION\n     - -----------\n     +@@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]\n     + \t   [--merged [<commit>]] [--no-merged [<commit>]]\n     + \t   [--contains [<commit>]] [--no-contains [<commit>]]\n     + \t   [--points-at <object>] [--format=<format>]\n     ++\t   [(--forked <branch>)...]\n     + \t   [(-r|--remotes) | (-a|--all)]\n     + \t   [--list] [<pattern>...]\n     + git branch [--track[=(direct|inherit)] | --no-track] [-f]\n      @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n       \tPrint the name of the current branch. In detached `HEAD` state,\n       \tnothing is printed.\n       \n     -+`--forked`::\n     -+\tList local branches whose configured upstream\n     -+\t(`branch.<name>.merge` resolved against `branch.<name>.remote`)\n     -+\tmatches any of the given _<branch>_ arguments.\n     -++\n     -+Each _<branch>_ is interpreted against the local repository: a literal\n     -+upstream like `origin/main` or a local branch like `master`, or a\n     -+wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n     -+(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,\n     -+to match the way `git checkout -b topic origin` picks a starting\n     -+point.  Multiple _<branch>_ arguments are unioned.\n     ++`--forked <branch>`::\n     ++\tList only branches whose configured upstream matches\n     ++\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n     ++\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n     ++\toption can be repeated to widen the filter.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c\n      +#include \"wildmatch.h\"\n       \n       static const char * const builtin_branch_usage[] = {\n     - \tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n     -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     - \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n     - \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     - \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] --forked <branch>...\"),\n     - \tNULL\n     - };\n     +-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n     ++\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n     + \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n     + \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n     + \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n     +@@ builtin/branch.c: static char *build_format(struct ref_filter *filter, int maxwidth, const char *r\n     + \treturn strbuf_detach(&fmt, NULL);\n     + }\n     + \n     ++static void filter_array_by_forked(struct ref_array *array,\n     ++\t\t\t\t   const struct string_list *upstreams);\n     ++\n     + static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,\n     +-\t\t\t   struct ref_format *format, struct string_list *output)\n     ++\t\t\t   struct ref_format *format, struct string_list *output,\n     ++\t\t\t   const struct string_list *forked_upstreams)\n     + {\n     + \tint i;\n     + \tstruct ref_array array;\n     +@@ builtin/branch.c: static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin\n     + \n     + \tfilter_refs(&array, filter, filter->kind);\n     + \n     ++\tif (forked_upstreams->nr)\n     ++\t\tfilter_array_by_forked(&array, forked_upstreams);\n     ++\n     + \tif (filter->verbose)\n     + \t\tmaxwidth = calc_maxwidth(&array, strlen(remote_prefix));\n       \n      @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n       \tfree_worktrees(worktrees);\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\n      +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n      +{\n     -+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     -+\tstruct remote *remote;\n      +\tstruct object_id oid;\n      +\tchar *full_ref = NULL;\n     -+\tstruct strbuf head_ref = STRBUF_INIT;\n     -+\tconst char *resolved;\n      +\n      +\tif (has_glob_specials(arg)) {\n      +\t\tout->name = xstrdup(arg);\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\t\treturn 0;\n      +\t}\n      +\n     -+\tremote = remote_get(arg);\n     -+\tif (remote && remote_is_configured(remote, 0)) {\n     -+\t\tstrbuf_addf(&head_ref, \"refs/remotes/%s/HEAD\", remote->name);\n     -+\t\tresolved = refs_resolve_ref_unsafe(refs, head_ref.buf,\n     -+\t\t\t\t\t\t   RESOLVE_REF_NO_RECURSE,\n     -+\t\t\t\t\t\t   NULL, NULL);\n     -+\t\tif (resolved && starts_with(resolved, \"refs/remotes/\")) {\n     -+\t\t\tout->name = xstrdup(short_upstream_name(resolved));\n     -+\t\t\tout->is_wildcard = 0;\n     -+\t\t\tstrbuf_release(&head_ref);\n     -+\t\t\treturn 0;\n     -+\t\t}\n     -+\t\tstrbuf_release(&head_ref);\n     -+\t}\n     -+\n      +\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n      +\t\t\t  &full_ref, 0) == 1 &&\n      +\t    (starts_with(full_ref, \"refs/heads/\") ||\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\treturn -1;\n      +}\n      +\n     -+static void parse_forked_args(int argc, const char **argv,\n     ++static void parse_forked_args(const struct string_list *args,\n      +\t\t\t      struct upstream_pattern **patterns_out,\n      +\t\t\t      size_t *nr_out)\n      +{\n      +\tstruct upstream_pattern *patterns;\n     -+\tint i;\n     ++\tsize_t i;\n      +\n     -+\tALLOC_ARRAY(patterns, argc);\n     -+\tfor (i = 0; i < argc; i++) {\n     -+\t\tif (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {\n     ++\tALLOC_ARRAY(patterns, args->nr);\n     ++\tfor (i = 0; i < args->nr; i++) {\n     ++\t\tconst char *arg = args->items[i].string;\n     ++\t\tif (parse_one_forked_arg(arg, &patterns[i]) < 0) {\n      +\t\t\tupstream_pattern_list_clear(patterns, i);\n     -+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n     -+\t\t\t    argv[i]);\n     ++\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n      +\t\t}\n      +\t}\n      +\t*patterns_out = patterns;\n     -+\t*nr_out = argc;\n     ++\t*nr_out = args->nr;\n      +}\n      +\n      +static int upstream_matches(const char *short_upstream,\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\treturn 0;\n      +}\n      +\n     -+struct forked_cb {\n     -+\tconst struct upstream_pattern *patterns;\n     -+\tsize_t nr_patterns;\n     -+\tstruct string_list *out;\n     -+};\n     -+\n     -+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n     ++static int branch_upstream_matches(const char *full_refname,\n     ++\t\t\t\t   const struct upstream_pattern *patterns,\n     ++\t\t\t\t   size_t nr_patterns)\n      +{\n     -+\tstruct forked_cb *cb = cb_data;\n     ++\tconst char *short_name;\n      +\tstruct branch *branch;\n      +\tconst char *upstream;\n      +\n     -+\tif (ref->flags & REF_ISSYMREF)\n     ++\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n      +\t\treturn 0;\n     -+\tbranch = branch_get(ref->name);\n     ++\tbranch = branch_get(short_name);\n      +\tif (!branch)\n      +\t\treturn 0;\n      +\tupstream = branch_get_upstream(branch, NULL);\n      +\tif (!upstream)\n      +\t\treturn 0;\n     -+\tif (upstream_matches(short_upstream_name(upstream),\n     -+\t\t\t     cb->patterns, cb->nr_patterns))\n     -+\t\tstring_list_append(cb->out, ref->name);\n     -+\treturn 0;\n     ++\treturn upstream_matches(short_upstream_name(upstream),\n     ++\t\t\t\tpatterns, nr_patterns);\n      +}\n      +\n     -+static int list_forked_branches(int argc, const char **argv)\n     ++static void filter_array_by_forked(struct ref_array *array,\n     ++\t\t\t\t   const struct string_list *upstreams)\n      +{\n      +\tstruct upstream_pattern *patterns = NULL;\n      +\tsize_t nr_patterns = 0;\n     -+\tstruct string_list out = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list_item *item;\n     -+\tstruct forked_cb cb;\n     -+\n     -+\tif (!argc)\n     -+\t\tdie(_(\"--forked requires at least one <branch>\"));\n     ++\tint i, kept = 0;\n      +\n     -+\tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n     -+\tcb.patterns = patterns;\n     -+\tcb.nr_patterns = nr_patterns;\n     -+\tcb.out = &out;\n     ++\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n      +\n     -+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     -+\t\t\t\t collect_forked_branch, &cb);\n     -+\n     -+\tstring_list_sort(&out);\n     -+\tfor_each_string_list_item(item, &out)\n     -+\t\tputs(item->string);\n     ++\tfor (i = 0; i < array->nr; i++) {\n     ++\t\tstruct ref_array_item *item = array->items[i];\n     ++\t\tif (branch_upstream_matches(item->refname,\n     ++\t\t\t\t\t    patterns, nr_patterns))\n     ++\t\t\tarray->items[kept++] = item;\n     ++\t\telse\n     ++\t\t\tfree_ref_array_item(item);\n     ++\t}\n     ++\tarray->nr = kept;\n      +\n      +\tupstream_pattern_list_clear(patterns, nr_patterns);\n     -+\tstring_list_clear(&out, 0);\n     -+\treturn 0;\n      +}\n      +\n       static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t/* possible actions */\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     -+\tint forked = 0;\n     ++\tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n       \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n     -+\t\tOPT_BOOL(0, \"forked\", &forked,\n     -+\t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n     ++\t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n     ++\t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\t     0);\n     - \n     - \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n     --\t    !show_current && !unset_upstream && argc == 0)\n     -+\t    !show_current && !unset_upstream && !forked && argc == 0)\n       \t\tlist = 1;\n       \n       \tif (filter.with_commit || filter.no_commit ||\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     +-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n     ++\t    filter.reachable_from || filter.unreachable_from ||\n     ++\t    filter.points_at.nr || forked_upstreams.nr)\n     + \t\tlist = 1;\n       \n       \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n     - \t\t\t    !!show_current + !!list + !!edit_description +\n     --\t\t\t    !!unset_upstream;\n     -+\t\t\t    !!unset_upstream + !!forked;\n     - \tif (noncreate_actions > 1)\n     - \t\tusage_with_options(builtin_branch_usage, options);\n     - \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\tdie(_(\"branch name required\"));\n     - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     - \t\tgoto out;\n     -+\t} else if (forked) {\n     -+\t\tret = list_forked_branches(argc, argv);\n     -+\t\tgoto out;\n     - \t} else if (show_current) {\n     - \t\tprint_current_branch_name();\n     - \t\tret = 0;\n     + \t\tref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);\n     + \t\tref_sorting_set_sort_flags_all(\n     + \t\t\tsorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);\n     +-\t\tprint_ref_list(&filter, sorting, &format, &output);\n     ++\t\tprint_ref_list(&filter, sorting, &format, &output,\n     ++\t\t\t       &forked_upstreams);\n     + \t\tprint_columns(&output, colopts, NULL);\n     + \t\tstring_list_clear(&output, 0);\n     + \t\tref_sorting_release(sorting);\n     +@@ builtin/branch.c: int cmd_branch(int argc,\n     + \n     + out:\n     + \tstring_list_clear(&sorting_options, 0);\n     ++\tstring_list_clear(&forked_upstreams, 0);\n     + \treturn ret;\n     + }\n     +\n     + ## ref-filter.c ##\n     +@@ ref-filter.c: static int filter_one(const struct reference *ref, void *cb_data)\n     + }\n     + \n     + /*  Free memory allocated for a ref_array_item */\n     +-static void free_array_item(struct ref_array_item *item)\n     ++void free_ref_array_item(struct ref_array_item *item)\n     + {\n     + \tfree((char *)item->symref);\n     + \tif (item->value) {\n     +@@ ref-filter.c: static int filter_and_format_one(const struct reference *ref, void *cb_data)\n     + \n     + \tstrbuf_release(&output);\n     + \tstrbuf_release(&err);\n     +-\tfree_array_item(item);\n     ++\tfree_ref_array_item(item);\n     + \n     + \t/*\n     + \t * Increment the running count of refs that match the filter. If\n     +@@ ref-filter.c: void ref_array_clear(struct ref_array *array)\n     + \tint i;\n     + \n     + \tfor (i = 0; i < array->nr; i++)\n     +-\t\tfree_array_item(array->items[i]);\n     ++\t\tfree_ref_array_item(array->items[i]);\n     + \tFREE_AND_NULL(array->items);\n     + \tarray->nr = array->alloc = 0;\n     + \n     +@@ ref-filter.c: static void reach_filter(struct ref_array *array,\n     + \t\tif (is_merged == include_reached)\n     + \t\t\tarray->items[array->nr++] = array->items[i];\n     + \t\telse\n     +-\t\t\tfree_array_item(item);\n     ++\t\t\tfree_ref_array_item(item);\n     + \t}\n     + \n     + \tclear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);\n     +@@ ref-filter.c: void pretty_print_ref(const char *name, const struct object_id *oid,\n     + \n     + \tstrbuf_release(&err);\n     + \tstrbuf_release(&output);\n     +-\tfree_array_item(ref_item);\n     ++\tfree_ref_array_item(ref_item);\n     + }\n     + \n     + static int parse_sorting_atom(const char *atom)\n     +\n     + ## ref-filter.h ##\n     +@@ ref-filter.h: void filter_and_format_refs(struct ref_filter *filter, unsigned int type,\n     + \t\t\t    struct ref_format *format);\n     + /*  Clear all memory allocated to ref_array */\n     + void ref_array_clear(struct ref_array *array);\n     ++/*  Free a single item from a ref_array */\n     ++void free_ref_array_item(struct ref_array_item *item);\n     + /*  Used to verify if the given format is correct and to parse out the used atoms */\n     + int verify_ref_format(struct ref_format *format);\n     + /*  Sort the given ref_array as per the ref_sorting provided */\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tgit -C forked branch --track local-trunk local-base\n      +'\n      +\n     -+test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '\n     -+\tgit -C forked branch --forked origin/one >actual &&\n     ++test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n     ++\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <glob> matches by wildmatch' '\n     -+\tgit -C forked branch --forked \"origin/*\" >actual &&\n     ++test_expect_success '--forked <glob> filters by wildmatch' '\n     ++\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n      +\tlocal-two\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n     -+\tgit -C forked branch --forked local-base >actual &&\n     ++\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n      +\techo local-trunk >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '\n     -+\ttest_when_finished \"git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main\" &&\n     -+\tgit -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&\n     -+\tgit -C forked branch --forked origin >actual &&\n     -+\techo local-one >expect &&\n     -+\ttest_cmp expect actual\n     -+'\n     -+\n     -+test_expect_success '--forked unions multiple <branch> arguments' '\n     -+\tgit -C forked branch --forked origin/one other/foreign >actual &&\n     ++test_expect_success '--forked can be repeated to widen the filter' '\n     ++\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-one\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked combines literal and glob arguments' '\n     -+\tgit -C forked branch --forked local-base \"other/*\" >actual &&\n     ++\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-trunk\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n     -+\tgit -C forked branch --forked \"*/*\" >actual &&\n     ++\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-one\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\ttest_cmp expect actual\n      +'\n      +\n     ++test_expect_success '--forked composes with --no-merged' '\n     ++\ttest_when_finished \"git -C forked checkout detached\" &&\n     ++\tgit -C forked checkout local-one &&\n     ++\ttest_commit -C forked local-only &&\n     ++\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n     ++\techo local-one >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n      +test_expect_success '--forked rejects unknown branch/pattern' '\n      +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n      +\ttest_grep \"not a valid branch or pattern\" err\n      +'\n      +\n     -+test_expect_success '--forked requires at least one <branch>' '\n     ++test_expect_success '--forked requires a value' '\n      +\ttest_must_fail git -C forked branch --forked 2>err &&\n     -+\ttest_grep \"at least one <branch>\" err\n     ++\ttest_grep \"requires a value\" err\n      +'\n      +\n       test_done\n 2:  b666d09bf5 ! 2:  6c95e4e77c branch: let delete_branches warn instead of error on bulk refusal\n     @@ Commit message\n          so a bulk caller can report not-fully-merged branches as one-line\n          warnings and continue, instead of erroring with the four-line \"use\n          'git branch -D'\" advice that the standalone \"git branch -d\" path\n     -    emits.  Default callers pass 0 and are unaffected.\n     +    emits. Default callers pass 0 and are unaffected.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ builtin/branch.c: int cmd_branch(int argc,\n      +\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n      +\t\t\t\t      quiet, 0);\n       \t\tgoto out;\n     - \t} else if (forked) {\n     - \t\tret = list_forked_branches(argc, argv);\n     + \t} else if (show_current) {\n     + \t\tprint_current_branch_name();\n 3:  6e6580270e ! 3:  004a96f7a4 branch: prepare delete_branches for a bulk caller\n     @@ Metadata\n       ## Commit message ##\n          branch: prepare delete_branches for a bulk caller\n      \n     -    Add no_head_fallback and dry_run flags to delete_branches() so a bulk\n     -    caller (the upcoming --prune-merged) can ask strictly about\n     -    merged-into-upstream without a silent fallback to HEAD, and rehearse\n     -    deletions with the same \"Would delete branch ...\" wording as the live\n     -    run.  Existing callers pass 0 for both and keep current behavior.\n     +    Add no_head_fallback and dry_run flags to delete_branches() so a\n     +    bulk caller (the upcoming --prune-merged) can ask strictly about\n     +    merged-into-upstream without a silent fallback to HEAD, and\n     +    rehearse deletions with the same \"Would delete branch ...\" wording\n     +    as the live run. Existing callers pass 0 for both and keep current\n     +    behavior.\n      \n          When no_head_fallback is set, head_rev stays NULL through to\n          branch_merged(), whose \"merged to X but not yet merged to HEAD\"\n     -    reminder otherwise compares against HEAD.  That reminder is only\n     -    meaningful when the caller actually cares about HEAD; for the\n     -    bulk caller every candidate is known to have an upstream and HEAD\n     -    is irrelevant to the decision.  Guard the block on head_rev so the\n     -    NULL case skips it instead of treating \"NULL != reference_rev\" as\n     -    \"diverges from HEAD\" and emitting a spurious warning.\n     +    reminder otherwise compares against HEAD. For the bulk caller\n     +    every candidate is known to have an upstream, so HEAD is\n     +    irrelevant. Guard the block on head_rev so the NULL case skips\n     +    it instead of treating \"NULL != reference_rev\" as \"diverges from\n     +    HEAD\" and emitting a spurious warning.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ builtin/branch.c: int cmd_branch(int argc,\n      -\t\t\t\t      quiet, 0);\n      +\t\t\t\t      quiet, 0, 0, 0);\n       \t\tgoto out;\n     - \t} else if (forked) {\n     - \t\tret = list_forked_branches(argc, argv);\n     + \t} else if (show_current) {\n     + \t\tprint_current_branch_name();\n 4:  e7e03c1338 ! 4:  cccfdb831c branch: add --prune-merged <branch>\n     @@ Commit message\n          upstream: the work has already landed on the upstream they track,\n          so the local copy is no longer needed.\n      \n     -    Reachability is read from the local refs only -- nothing is\n     -    fetched. Users who want fresh upstream refs run \"git fetch\" first;\n     -    the deletion path stays a separate, idempotent step that also\n     -    works offline.\n     +    Reachability is read from local refs; nothing is fetched. Users\n     +    who want fresh upstream refs run \"git fetch\" first.\n      \n          Three classes of branches are spared:\n      \n     @@ Commit message\n            * any branch whose push destination equals its upstream\n              (<branch>@{push} == <branch>@{upstream}). Such a branch\n              cannot be distinguished from a freshly pulled trunk that\n     -        just looks \"fully merged\" -- e.g. local \"main\" tracking and\n     +        just looks \"fully merged\", e.g. local \"main\" tracking and\n              pushing to \"origin/main\" right after a pull. Only branches\n              that push somewhere other than their upstream (typically\n              topics in a fork-based workflow) are treated as candidates.\n     @@ Commit message\n          mode and with the HEAD-fallback disabled: a branch that is not\n          yet fully merged to its upstream is reported as a one-line warning\n          and skipped, so a single un-mergeable topic does not abort the\n     -    whole sweep, and there is no fallback to \"merged into the\n     -    currently checked out branch\" -- we only act on upstream-merged\n     -    status.\n     +    whole sweep. We only act on upstream-merged status.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n     +@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n     + git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     - git branch --forked <branch>...\n     -+git branch --prune-merged <branch>...\n     ++git branch (--prune-merged <branch>)...\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare configured-remote name\n     - to match the way `git checkout -b topic origin` picks a starting\n     - point.  Multiple _<branch>_ arguments are unioned.\n     +@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n     + \t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n     + \toption can be repeated to widen the filter.\n       \n     -+`--prune-merged`::\n     ++`--prune-merged <branch>`::\n      +\tDelete the local branches that `--forked` would list for the\n     -+\tsame _<branch>_ arguments, but only those whose tip is\n     -+\treachable from their configured upstream.  In other words,\n     -+\tthe work on the branch has already landed on the upstream it\n     -+\ttracks, so the local copy is no longer needed.\n     ++\tsame _<branch>_, but only those whose tip is reachable from\n     ++\ttheir configured upstream. In other words, the work on the\n     ++\tbranch has already landed on the upstream it tracks, so the\n     ++\tlocal copy is no longer needed. May be given more than once to\n     ++\tunion the matches; positional arguments are not accepted.\n      ++\n      +Reachability is checked against whatever the upstream refs say\n     -+locally; nothing is fetched.  Run `git fetch` first if you want\n     ++locally; nothing is fetched. Run `git fetch` first if you want\n      +the upstream refs refreshed.\n      ++\n      +A branch is left alone if any of the following holds:\n     @@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`.  A bare conf\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)\n     +@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n     + \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n     + \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     + \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     ++\tN_(\"git branch [<options>] (--prune-merged <branch>)...\"),\n     + \tNULL\n     + };\n     + \n     +@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,\n       \treturn 0;\n       }\n       \n     --static int list_forked_branches(int argc, const char **argv)\n     -+static void collect_forked_set(int argc, const char **argv,\n     -+\t\t\t       struct string_list *out)\n     +-static int branch_upstream_matches(const char *full_refname,\n     ++static int branch_upstream_matches(const char *short_branch_name,\n     + \t\t\t\t   const struct upstream_pattern *patterns,\n     + \t\t\t\t   size_t nr_patterns)\n       {\n     - \tstruct upstream_pattern *patterns = NULL;\n     - \tsize_t nr_patterns = 0;\n     --\tstruct string_list out = STRING_LIST_INIT_DUP;\n     --\tstruct string_list_item *item;\n     - \tstruct forked_cb cb;\n     +-\tconst char *short_name;\n     +-\tstruct branch *branch;\n     ++\tstruct branch *branch = branch_get(short_branch_name);\n     + \tconst char *upstream;\n       \n     --\tif (!argc)\n     --\t\tdie(_(\"--forked requires at least one <branch>\"));\n     --\n     - \tparse_forked_args(argc, argv, &patterns, &nr_patterns);\n     - \tcb.patterns = patterns;\n     - \tcb.nr_patterns = nr_patterns;\n     --\tcb.out = &out;\n     -+\tcb.out = out;\n     +-\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n     +-\t\treturn 0;\n     +-\tbranch = branch_get(short_name);\n     + \tif (!branch)\n     + \t\treturn 0;\n     + \tupstream = branch_get_upstream(branch, NULL);\n     +@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n       \n     - \trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     - \t\t\t\t collect_forked_branch, &cb);\n     + \tfor (i = 0; i < array->nr; i++) {\n     + \t\tstruct ref_array_item *item = array->items[i];\n     +-\t\tif (branch_upstream_matches(item->refname,\n     +-\t\t\t\t\t    patterns, nr_patterns))\n     ++\t\tconst char *short_name;\n     ++\t\tif (skip_prefix(item->refname, \"refs/heads/\", &short_name) &&\n     ++\t\t    branch_upstream_matches(short_name, patterns, nr_patterns))\n     + \t\t\tarray->items[kept++] = item;\n     + \t\telse\n     + \t\t\tfree_ref_array_item(item);\n     +@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n     + \tupstream_pattern_list_clear(patterns, nr_patterns);\n     + }\n       \n     --\tstring_list_sort(&out);\n     -+\tstring_list_sort(out);\n     ++struct forked_cb {\n     ++\tconst struct upstream_pattern *patterns;\n     ++\tsize_t nr_patterns;\n     ++\tstruct string_list *out;\n     ++};\n      +\n     -+\tupstream_pattern_list_clear(patterns, nr_patterns);\n     ++static int collect_forked_branch(const struct reference *ref, void *cb_data)\n     ++{\n     ++\tstruct forked_cb *cb = cb_data;\n     ++\n     ++\tif (ref->flags & REF_ISSYMREF)\n     ++\t\treturn 0;\n     ++\tif (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))\n     ++\t\tstring_list_append(cb->out, ref->name);\n     ++\treturn 0;\n      +}\n      +\n     -+static int list_forked_branches(int argc, const char **argv)\n     ++static void collect_forked_set(const struct string_list *upstreams,\n     ++\t\t\t       struct string_list *out)\n      +{\n     -+\tstruct string_list out = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list_item *item;\n     ++\tstruct upstream_pattern *patterns = NULL;\n     ++\tsize_t nr_patterns = 0;\n     ++\tstruct forked_cb cb;\n      +\n     -+\tif (!argc)\n     -+\t\tdie(_(\"--forked requires at least one <branch>\"));\n     ++\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n     ++\tcb.patterns = patterns;\n     ++\tcb.nr_patterns = nr_patterns;\n     ++\tcb.out = out;\n      +\n     -+\tcollect_forked_set(argc, argv, &out);\n     - \tfor_each_string_list_item(item, &out)\n     - \t\tputs(item->string);\n     - \n     --\tupstream_pattern_list_clear(patterns, nr_patterns);\n     - \tstring_list_clear(&out, 0);\n     - \treturn 0;\n     - }\n     - \n     -+static int prune_merged_branches(int argc, const char **argv, int quiet)\n     ++\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     ++\t\t\t\t collect_forked_branch, &cb);\n     ++\n     ++\tstring_list_sort(out);\n     ++\n     ++\tupstream_pattern_list_clear(patterns, nr_patterns);\n     ++}\n     ++\n     ++static int prune_merged_branches(const struct string_list *upstreams,\n     ++\t\t\t\t int quiet)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      +\tstruct string_list_item *item;\n      +\tint ret = 0;\n      +\n     -+\tif (!argc)\n     ++\tif (!upstreams->nr)\n      +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n      +\n     -+\tcollect_forked_set(argc, argv, &candidates);\n     ++\tcollect_forked_set(upstreams, &candidates);\n      +\n      +\tfor_each_string_list_item(item, &candidates) {\n      +\t\tconst char *short_name = item->string;\n     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tint forked = 0;\n     -+\tint prune_merged = 0;\n     + \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t N_(\"edit the description for the branch\")),\n     - \t\tOPT_BOOL(0, \"forked\", &forked,\n     - \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n     -+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     -+\t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n     + \t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n     + \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n     ++\t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n     ++\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t     0);\n       \n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n     --\t    !show_current && !unset_upstream && !forked && argc == 0)\n     -+\t    !show_current && !unset_upstream && !forked && !prune_merged &&\n     +-\t    !show_current && !unset_upstream && argc == 0)\n     ++\t    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&\n      +\t    argc == 0)\n       \t\tlist = 1;\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \n       \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n       \t\t\t    !!show_current + !!list + !!edit_description +\n     --\t\t\t    !!unset_upstream + !!forked;\n     -+\t\t\t    !!unset_upstream + !!forked + !!prune_merged;\n     +-\t\t\t    !!unset_upstream;\n     ++\t\t\t    !!unset_upstream + !!prune_merged_upstreams.nr;\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t} else if (forked) {\n     - \t\tret = list_forked_branches(argc, argv);\n     + \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     + \t\t\t\t      quiet, 0, 0, 0);\n       \t\tgoto out;\n     -+\t} else if (prune_merged) {\n     -+\t\tret = prune_merged_branches(argc, argv, quiet);\n     ++\t} else if (prune_merged_upstreams.nr) {\n     ++\t\tif (argc)\n     ++\t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n     ++\t\t\t      \"repeat --prune-merged for each <branch>\"));\n     ++\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n       \t\tret = 0;\n     +@@ builtin/branch.c: int cmd_branch(int argc,\n     + out:\n     + \tstring_list_clear(&sorting_options, 0);\n     + \tstring_list_clear(&forked_upstreams, 0);\n     ++\tstring_list_clear(&prune_merged_upstreams, 0);\n     + \treturn ret;\n     + }\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>' '\n     - \ttest_grep \"at least one <branch>\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n     + \ttest_grep \"requires a value\" err\n       '\n       \n      +test_expect_success '--prune-merged: setup' '\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n      +\tgit -C pm-union checkout --detach &&\n      +\n     -+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n     ++\tgit -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&\n      +\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'\n      +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n      +'\n      +\n     -+test_expect_success '--prune-merged requires at least one <branch>' '\n     ++test_expect_success '--prune-merged requires a value' '\n      +\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n     -+\ttest_grep \"at least one <branch>\" err\n     ++\ttest_grep \"requires a value\" err\n     ++'\n     ++\n     ++test_expect_success '--prune-merged rejects positional arguments' '\n     ++\ttest_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&\n     ++\ttest_grep \"does not take positional arguments\" err\n      +'\n      +\n       test_done\n 5:  75b6d2366a ! 5:  5f793f8d0d branch: add branch.<name>.pruneMerged opt-out\n     @@ Documentation/git-branch.adoc: the upstream refs refreshed.\n       warnings and skipped; pass them to `git branch -D` explicitly if\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n       \t\tstruct branch *branch = branch_get(short_name);\n       \t\tconst char *upstream, *push;\n       \t\tstruct strbuf full = STRBUF_INIT;\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \n       \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n       \t\tskip = !!branch_checked_out(full.buf);\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n       \t\tif (!push || !strcmp(push, upstream))\n       \t\t\tcontinue;\n       \n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '\n     - \ttest_grep \"at least one <branch>\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '\n     + \ttest_grep \"does not take positional arguments\" err\n       '\n       \n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n 6:  a1a42a6b19 ! 6:  1a0d5eab15 branch: add --dry-run for --prune-merged\n     @@ Commit message\n          branch: add --dry-run for --prune-merged\n      \n          With --dry-run, --prune-merged prints the local branches it would\n     -    delete -- one \"Would delete branch <name>\" line per candidate --\n     -    and exits without touching any ref.\n     +    delete, one \"Would delete branch <name>\" line per candidate, and\n     +    exits without touching any ref.\n      \n     -    This is the natural sanity check before letting a broad pattern\n     -    like 'origin/*' run for real: the @{push}-vs-@{upstream} and\n     -    unmerged filtering still applies, so the dry-run output is\n     -    exactly the set that the live run would delete.\n     +    The @{push}-vs-@{upstream} and unmerged filtering still applies,\n     +    so the dry-run output is exactly the set that the live run would\n     +    delete.\n      \n          --dry-run is only meaningful in combination with --prune-merged\n          and is rejected otherwise.\n     @@ Commit message\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>\n     +@@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n     + git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     - git branch --forked <branch>...\n     --git branch --prune-merged <branch>...\n     -+git branch --prune-merged [--dry-run] <branch>...\n     +-git branch (--prune-merged <branch>)...\n     ++git branch [--dry-run] (--prune-merged <branch>)...\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety che\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)\n     - \treturn 0;\n     +@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,\n       }\n       \n     --static int prune_merged_branches(int argc, const char **argv, int quiet)\n     -+static int prune_merged_branches(int argc, const char **argv, int quiet,\n     -+\t\t\t\t int dry_run)\n     + static int prune_merged_branches(const struct string_list *upstreams,\n     +-\t\t\t\t int quiet)\n     ++\t\t\t\t int quiet, int dry_run)\n       {\n       \tstruct ref_store *refs = get_main_ref_store(the_repository);\n       \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)\n     +@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n       \t\t\t\t      quiet,\n       \t\t\t\t      1, /* warn_only */\n       \t\t\t\t      1, /* no_head_fallback */\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \tstring_list_clear(&candidates, 0);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tint forked = 0;\n     - \tint prune_merged = 0;\n     + \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n     + \tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n      +\tint dry_run = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\tN_(\"list local branches whose upstream matches the given <branch>...\")),\n     - \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     - \t\t\tN_(\"delete local branches whose upstream matches the given <branch>... and is merged\")),\n     + \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n     + \t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n     + \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n      +\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     -+\tif (dry_run && !prune_merged)\n     ++\tif (dry_run && !prune_merged_upstreams.nr)\n      +\t\tdie(_(\"--dry-run requires --prune-merged\"));\n      +\n       \tif (recurse_submodules_explicit) {\n       \t\tif (!submodule_propagate_branches)\n       \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tret = list_forked_branches(argc, argv);\n     - \t\tgoto out;\n     - \t} else if (prune_merged) {\n     --\t\tret = prune_merged_branches(argc, argv, quiet);\n     -+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n     + \t\tif (argc)\n     + \t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n     + \t\t\t      \"repeat --prune-merged for each <branch>\"));\n     +-\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n     ++\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n      +\tgit -C pm-dry branch two two-commit &&\n      +\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n      +\n     -+\tgit -C pm-dry branch --prune-merged --dry-run \"origin/*\" >actual &&\n     ++\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n      +\ttest_grep \"Would delete branch one \" actual &&\n      +\ttest_grep \"Would delete branch two \" actual &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n      +\tgit -C pm-dry-mixed branch merged one-commit &&\n      +\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n      +\n     -+\tgit -C pm-dry-mixed branch --prune-merged --dry-run \"origin/*\" >out &&\n     ++\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n      +\ttest_grep \"Would delete branch merged\" out &&\n      +\ttest_grep ! \"Would delete branch wip\" out &&\n      +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n\n-- \ngitgitgadget\n"},{"id":"544587","messageId":"8834c424fbd27800636fe21ae73e9cdce75b558a.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 1/6] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:34Z","receivedAt":"2026-06-03T09:04:44Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that keeps only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\") or a shell-style\nglob (e.g. \"origin/*\"). The option can be repeated to widen the\nfilter.\n\nBecause it is a filter on list mode, --forked composes with the\nexisting list-mode filters, so\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that have already been\nintegrated into origin/main, and --no-merged inverts the question.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |   7 ++\n builtin/branch.c              | 147 +++++++++++++++++++++++++++++++++-\n ref-filter.c                  |  10 +--\n ref-filter.h                  |   2 +\n t/t3200-branch.sh             |  92 +++++++++++++++++++++\n 5 files changed, 249 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..8002d7f38c 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n \t   [--points-at <object>] [--format=<format>]\n+\t   [(--forked <branch>)...]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n git branch [--track[=(direct|inherit)] | --no-track] [-f]\n@@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--forked <branch>`::\n+\tList only branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n+\toption can be repeated to widen the filter.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..12711b29cf 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -28,9 +28,10 @@\n #include \"help.h\"\n #include \"advice.h\"\n #include \"commit-reach.h\"\n+#include \"wildmatch.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r\n \treturn strbuf_detach(&fmt, NULL);\n }\n \n+static void filter_array_by_forked(struct ref_array *array,\n+\t\t\t\t   const struct string_list *upstreams);\n+\n static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,\n-\t\t\t   struct ref_format *format, struct string_list *output)\n+\t\t\t   struct ref_format *format, struct string_list *output,\n+\t\t\t   const struct string_list *forked_upstreams)\n {\n \tint i;\n \tstruct ref_array array;\n@@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin\n \n \tfilter_refs(&array, filter, filter->kind);\n \n+\tif (forked_upstreams->nr)\n+\t\tfilter_array_by_forked(&array, forked_upstreams);\n+\n \tif (filter->verbose)\n \t\tmaxwidth = calc_maxwidth(&array, strlen(remote_prefix));\n \n@@ -673,6 +681,131 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+struct upstream_pattern {\n+\tchar *name;\n+\tint is_wildcard;\n+};\n+\n+static void upstream_pattern_list_clear(struct upstream_pattern *items,\n+\t\t\t\t\tsize_t nr)\n+{\n+\tsize_t i;\n+\tfor (i = 0; i < nr; i++)\n+\t\tfree(items[i].name);\n+\tfree(items);\n+}\n+\n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tout->name = xstrdup(arg);\n+\t\tout->is_wildcard = 1;\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tout->name = xstrdup(short_upstream_name(full_ref));\n+\t\tout->is_wildcard = 0;\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n+static void parse_forked_args(const struct string_list *args,\n+\t\t\t      struct upstream_pattern **patterns_out,\n+\t\t\t      size_t *nr_out)\n+{\n+\tstruct upstream_pattern *patterns;\n+\tsize_t i;\n+\n+\tALLOC_ARRAY(patterns, args->nr);\n+\tfor (i = 0; i < args->nr; i++) {\n+\t\tconst char *arg = args->items[i].string;\n+\t\tif (parse_one_forked_arg(arg, &patterns[i]) < 0) {\n+\t\t\tupstream_pattern_list_clear(patterns, i);\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\t\t}\n+\t}\n+\t*patterns_out = patterns;\n+\t*nr_out = args->nr;\n+}\n+\n+static int upstream_matches(const char *short_upstream,\n+\t\t\t    const struct upstream_pattern *patterns,\n+\t\t\t    size_t nr)\n+{\n+\tsize_t i;\n+\n+\tfor (i = 0; i < nr; i++) {\n+\t\tconst struct upstream_pattern *p = &patterns[i];\n+\t\tif (p->is_wildcard) {\n+\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(p->name, short_upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+static int branch_upstream_matches(const char *full_refname,\n+\t\t\t\t   const struct upstream_pattern *patterns,\n+\t\t\t\t   size_t nr_patterns)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\n+\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\treturn upstream_matches(short_upstream_name(upstream),\n+\t\t\t\tpatterns, nr_patterns);\n+}\n+\n+static void filter_array_by_forked(struct ref_array *array,\n+\t\t\t\t   const struct string_list *upstreams)\n+{\n+\tstruct upstream_pattern *patterns = NULL;\n+\tsize_t nr_patterns = 0;\n+\tint i, kept = 0;\n+\n+\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n+\n+\tfor (i = 0; i < array->nr; i++) {\n+\t\tstruct ref_array_item *item = array->items[i];\n+\t\tif (branch_upstream_matches(item->refname,\n+\t\t\t\t\t    patterns, nr_patterns))\n+\t\t\tarray->items[kept++] = item;\n+\t\telse\n+\t\t\tfree_ref_array_item(item);\n+\t}\n+\tarray->nr = kept;\n+\n+\tupstream_pattern_list_clear(patterns, nr_patterns);\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -714,6 +847,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -767,6 +901,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n+\t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -815,7 +951,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || forked_upstreams.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n@@ -880,7 +1017,8 @@ int cmd_branch(int argc,\n \t\tref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);\n \t\tref_sorting_set_sort_flags_all(\n \t\t\tsorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);\n-\t\tprint_ref_list(&filter, sorting, &format, &output);\n+\t\tprint_ref_list(&filter, sorting, &format, &output,\n+\t\t\t       &forked_upstreams);\n \t\tprint_columns(&output, colopts, NULL);\n \t\tstring_list_clear(&output, 0);\n \t\tref_sorting_release(sorting);\n@@ -1020,5 +1158,6 @@ int cmd_branch(int argc,\n \n out:\n \tstring_list_clear(&sorting_options, 0);\n+\tstring_list_clear(&forked_upstreams, 0);\n \treturn ret;\n }\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..65e7bc6785 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)\n }\n \n /*  Free memory allocated for a ref_array_item */\n-static void free_array_item(struct ref_array_item *item)\n+void free_ref_array_item(struct ref_array_item *item)\n {\n \tfree((char *)item->symref);\n \tif (item->value) {\n@@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)\n \n \tstrbuf_release(&output);\n \tstrbuf_release(&err);\n-\tfree_array_item(item);\n+\tfree_ref_array_item(item);\n \n \t/*\n \t * Increment the running count of refs that match the filter. If\n@@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)\n \tint i;\n \n \tfor (i = 0; i < array->nr; i++)\n-\t\tfree_array_item(array->items[i]);\n+\t\tfree_ref_array_item(array->items[i]);\n \tFREE_AND_NULL(array->items);\n \tarray->nr = array->alloc = 0;\n \n@@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,\n \t\tif (is_merged == include_reached)\n \t\t\tarray->items[array->nr++] = array->items[i];\n \t\telse\n-\t\t\tfree_array_item(item);\n+\t\t\tfree_ref_array_item(item);\n \t}\n \n \tclear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);\n@@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,\n \n \tstrbuf_release(&err);\n \tstrbuf_release(&output);\n-\tfree_array_item(ref_item);\n+\tfree_ref_array_item(ref_item);\n }\n \n static int parse_sorting_atom(const char *atom)\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..3883b9dc62 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,\n \t\t\t    struct ref_format *format);\n /*  Clear all memory allocated to ref_array */\n void ref_array_clear(struct ref_array *array);\n+/*  Free a single item from a ref_array */\n+void free_ref_array_item(struct ref_array_item *item);\n /*  Used to verify if the given format is correct and to parse out the used atoms */\n int verify_ref_format(struct ref_format *format);\n /*  Sort the given ref_array as per the ref_sorting provided */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..4e7deddc04 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch local-base &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached &&\n+\tgit -C forked branch --track local-trunk local-base\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-trunk >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-trunk\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout detached\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544588","messageId":"6c95e4e77cf555194b83eab92c7564e6b639f500.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:35Z","receivedAt":"2026-06-03T09:04:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a warn_only flag to delete_branches() and check_branch_commit()\nso a bulk caller can report not-fully-merged branches as one-line\nwarnings and continue, instead of erroring with the four-line \"use\n'git branch -D'\" advice that the standalone \"git branch -d\" path\nemits. Default callers pass 0 and are unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 26 +++++++++++++++++---------\n 1 file changed, 17 insertions(+), 9 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 12711b29cf..93d8eae891 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,7 +192,7 @@ static int branch_merged(int kind, const char *name,\n \n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, int force, int warn_only)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n@@ -200,10 +200,16 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (warn_only) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -219,7 +225,7 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+\t\t\t   int quiet, int warn_only)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -309,8 +315,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tforce, warn_only)) {\n+\t\t\tif (!warn_only)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n@@ -995,7 +1002,8 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n+\t\t\t\t      quiet, 0);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"544589","messageId":"004a96f7a447ad8dcbcabeb36502330c2399f829.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:36Z","receivedAt":"2026-06-03T09:04:49Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd no_head_fallback and dry_run flags to delete_branches() so a\nbulk caller (the upcoming --prune-merged) can ask strictly about\nmerged-into-upstream without a silent fallback to HEAD, and\nrehearse deletions with the same \"Would delete branch ...\" wording\nas the live run. Existing callers pass 0 for both and keep current\nbehavior.\n\nWhen no_head_fallback is set, head_rev stays NULL through to\nbranch_merged(), whose \"merged to X but not yet merged to HEAD\"\nreminder otherwise compares against HEAD. For the bulk caller\nevery candidate is known to have an upstream, so HEAD is\nirrelevant. Guard the block on head_rev so the NULL case skips\nit instead of treating \"NULL != reference_rev\" as \"diverges from\nHEAD\" and emitting a spurious warning.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 27 +++++++++++++++++++--------\n 1 file changed, 19 insertions(+), 8 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 93d8eae891..09afdd9257 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -169,10 +169,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -225,7 +228,8 @@ static void delete_branch_config(const char *branchname)\n }\n \n static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet, int warn_only)\n+\t\t\t   int quiet, int warn_only, int no_head_fallback,\n+\t\t\t   int dry_run)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -259,7 +263,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -330,13 +334,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n@@ -1003,7 +1014,7 @@ int cmd_branch(int argc,\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n-\t\t\t\t      quiet, 0);\n+\t\t\t\t      quiet, 0, 0, 0);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"544591","messageId":"cccfdb831cc8c4ca0844d5b4ba2b70f0c801fc59.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:37Z","receivedAt":"2026-06-03T09:04:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --prune-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nrestricted to those whose tip is reachable from their configured\nupstream: the work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nReachability is read from local refs; nothing is fetched. Users\nwho want fresh upstream refs run \"git fetch\" first.\n\nThree classes of branches are spared:\n\n  * any branch checked out in any worktree;\n  * any branch whose upstream no longer resolves locally (its\n    disappearance is not, on its own, evidence of integration);\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} == <branch>@{upstream}). Such a branch\n    cannot be distinguished from a freshly pulled trunk that\n    just looks \"fully merged\", e.g. local \"main\" tracking and\n    pushing to \"origin/main\" right after a pull. Only branches\n    that push somewhere other than their upstream (typically\n    topics in a fork-based workflow) are treated as candidates.\n\nDeletion goes through the existing delete_branches() in warn-only\nmode and with the HEAD-fallback disabled: a branch that is not\nyet fully merged to its upstream is reported as a one-line warning\nand skipped, so a single un-mergeable topic does not abort the\nwhole sweep. We only act on upstream-merged status.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  23 +++++\n builtin/branch.c              | 117 +++++++++++++++++++--\n t/t3200-branch.sh             | 188 ++++++++++++++++++++++++++++++++++\n 3 files changed, 318 insertions(+), 10 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 8002d7f38c..f7942fcd7d 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--prune-merged <branch>)...\n \n DESCRIPTION\n -----------\n@@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.\n \t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n \toption can be repeated to widen the filter.\n \n+`--prune-merged <branch>`::\n+\tDelete the local branches that `--forked` would list for the\n+\tsame _<branch>_, but only those whose tip is reachable from\n+\ttheir configured upstream. In other words, the work on the\n+\tbranch has already landed on the upstream it tracks, so the\n+\tlocal copy is no longer needed. May be given more than once to\n+\tunion the matches; positional arguments are not accepted.\n++\n+Reachability is checked against whatever the upstream refs say\n+locally; nothing is fetched. Run `git fetch` first if you want\n+the upstream refs refreshed.\n++\n+A branch is left alone if any of the following holds:\n+its upstream no longer resolves locally; it is checked out in any\n+worktree; or its push destination (`<branch>@{push}`) equals its\n+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n+from a freshly pulled trunk that just looks \"fully merged\".\n++\n+Branches refused by the \"fully merged\" safety check are listed as\n+warnings and skipped; pass them to `git branch -D` explicitly if\n+you want them gone.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 09afdd9257..736480b002 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--prune-merged <branch>)...\"),\n \tNULL\n };\n \n@@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,\n \treturn 0;\n }\n \n-static int branch_upstream_matches(const char *full_refname,\n+static int branch_upstream_matches(const char *short_branch_name,\n \t\t\t\t   const struct upstream_pattern *patterns,\n \t\t\t\t   size_t nr_patterns)\n {\n-\tconst char *short_name;\n-\tstruct branch *branch;\n+\tstruct branch *branch = branch_get(short_branch_name);\n \tconst char *upstream;\n \n-\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n-\t\treturn 0;\n-\tbranch = branch_get(short_name);\n \tif (!branch)\n \t\treturn 0;\n \tupstream = branch_get_upstream(branch, NULL);\n@@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,\n \n \tfor (i = 0; i < array->nr; i++) {\n \t\tstruct ref_array_item *item = array->items[i];\n-\t\tif (branch_upstream_matches(item->refname,\n-\t\t\t\t\t    patterns, nr_patterns))\n+\t\tconst char *short_name;\n+\t\tif (skip_prefix(item->refname, \"refs/heads/\", &short_name) &&\n+\t\t    branch_upstream_matches(short_name, patterns, nr_patterns))\n \t\t\tarray->items[kept++] = item;\n \t\telse\n \t\t\tfree_ref_array_item(item);\n@@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,\n \tupstream_pattern_list_clear(patterns, nr_patterns);\n }\n \n+struct forked_cb {\n+\tconst struct upstream_pattern *patterns;\n+\tsize_t nr_patterns;\n+\tstruct string_list *out;\n+};\n+\n+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n+{\n+\tstruct forked_cb *cb = cb_data;\n+\n+\tif (ref->flags & REF_ISSYMREF)\n+\t\treturn 0;\n+\tif (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))\n+\t\tstring_list_append(cb->out, ref->name);\n+\treturn 0;\n+}\n+\n+static void collect_forked_set(const struct string_list *upstreams,\n+\t\t\t       struct string_list *out)\n+{\n+\tstruct upstream_pattern *patterns = NULL;\n+\tsize_t nr_patterns = 0;\n+\tstruct forked_cb cb;\n+\n+\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n+\tcb.patterns = patterns;\n+\tcb.nr_patterns = nr_patterns;\n+\tcb.out = out;\n+\n+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n+\t\t\t\t collect_forked_branch, &cb);\n+\n+\tstring_list_sort(out);\n+\n+\tupstream_pattern_list_clear(patterns, nr_patterns);\n+}\n+\n+static int prune_merged_branches(const struct string_list *upstreams,\n+\t\t\t\t int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tstruct string_list_item *item;\n+\tint ret = 0;\n+\n+\tif (!upstreams->nr)\n+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n+\n+\tcollect_forked_set(upstreams, &candidates);\n+\n+\tfor_each_string_list_item(item, &candidates) {\n+\t\tconst char *short_name = item->string;\n+\t\tstruct branch *branch = branch_get(short_name);\n+\t\tconst char *upstream, *push;\n+\t\tstruct strbuf full = STRBUF_INIT;\n+\t\tint skip;\n+\n+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n+\t\tskip = !!branch_checked_out(full.buf);\n+\t\tstrbuf_release(&full);\n+\t\tif (skip)\n+\t\t\tcontinue;\n+\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      0, /* force */\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      quiet,\n+\t\t\t\t      1, /* warn_only */\n+\t\t\t\t      1, /* no_head_fallback */\n+\t\t\t\t      0  /* dry_run */);\n+\n+\tstrvec_clear(&deletable);\n+\tstring_list_clear(&candidates, 0);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -866,6 +952,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n+\tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -921,6 +1008,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n+\t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -965,7 +1054,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -975,7 +1065,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!prune_merged_upstreams.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -1016,6 +1106,12 @@ int cmd_branch(int argc,\n \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n \t\t\t\t      quiet, 0, 0, 0);\n \t\tgoto out;\n+\t} else if (prune_merged_upstreams.nr) {\n+\t\tif (argc)\n+\t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n+\t\t\t      \"repeat --prune-merged for each <branch>\"));\n+\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1178,5 +1274,6 @@ int cmd_branch(int argc,\n out:\n \tstring_list_clear(&sorting_options, 0);\n \tstring_list_clear(&forked_upstreams, 0);\n+\tstring_list_clear(&prune_merged_upstreams, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4e7deddc04..beb86987ad 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main &&\n+\ttest_create_repo pm-fork\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged remote add fork ../pm-fork &&\n+\ttest_config -C pm-merged remote.pushDefault fork &&\n+\ttest_config -C pm-merged push.default current &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a literal upstream' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal remote add fork ../pm-fork &&\n+\ttest_config -C pm-literal remote.pushDefault fork &&\n+\ttest_config -C pm-literal push.default current &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-literal branch --prune-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union remote add fork ../pm-fork &&\n+\ttest_config -C pm-union remote.pushDefault fork &&\n+\ttest_config -C pm-union push.default current &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-union checkout --detach &&\n+\n+\tgit -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a local upstream' '\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\tgit clone pm-upstream pm-local &&\n+\tgit -C pm-local remote add fork ../pm-fork &&\n+\ttest_config -C pm-local remote.pushDefault fork &&\n+\ttest_config -C pm-local push.default current &&\n+\tgit -C pm-local checkout -b trunk &&\n+\tgit -C pm-local branch one one-commit &&\n+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n+\tgit -C pm-local merge --ff-only one-commit &&\n+\n+\tgit -C pm-local branch --prune-merged trunk &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n+\ttest_config -C pm-unmerged push.default current &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n+\ttest_when_finished \"rm -rf pm-nohead\" &&\n+\tgit clone pm-upstream pm-nohead &&\n+\tgit -C pm-nohead remote add fork ../pm-fork &&\n+\ttest_config -C pm-nohead remote.pushDefault fork &&\n+\ttest_config -C pm-nohead push.default current &&\n+\tgit -C pm-nohead branch topic one-commit &&\n+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n+\n+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n+\n+\ttest_grep ! \"not yet merged to HEAD\" err &&\n+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n+\ttest_config -C pm-upstream-gone push.default current &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head remote add fork ../pm-fork &&\n+\ttest_config -C pm-head remote.pushDefault fork &&\n+\ttest_config -C pm-head push.default current &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n+\tgit clone pm-upstream pm-push-eq &&\n+\tgit -C pm-push-eq checkout --detach &&\n+\n+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n+\tgit clone pm-upstream pm-push-branch &&\n+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n+\ttest_config -C pm-push-branch push.default current &&\n+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n+\tgit -C pm-push-branch checkout --detach &&\n+\n+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n+\tgit clone pm-upstream pm-push-diff &&\n+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n+\ttest_config -C pm-push-diff push.default current &&\n+\tgit -C pm-push-diff branch topic one-commit &&\n+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n+\tgit -C pm-push-diff checkout --detach &&\n+\n+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged requires a value' '\n+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n+test_expect_success '--prune-merged rejects positional arguments' '\n+\ttest_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&\n+\ttest_grep \"does not take positional arguments\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544592","messageId":"5f793f8d0d5e4fd5b2eea73905092c45039a1d3c.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:38Z","receivedAt":"2026-06-03T09:04:53Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n\"git branch --prune-merged\". Useful for a topic branch you want\nto develop further after an initial round has been merged\nupstream.\n\nUnless --quiet is given, the skip is reported per branch so the\nuser knows why their topic was preserved.\n\nExplicit deletion via \"git branch -d\" continues to consult the\nnormal merge check and is not affected by this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 ++++++++++++++\n t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++\n 4 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..6c1b5bb9cd 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex f7942fcd7d..69878549fc 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -221,9 +221,10 @@ the upstream refs refreshed.\n +\n A branch is left alone if any of the following holds:\n its upstream no longer resolves locally; it is checked out in any\n-worktree; or its push destination (`<branch>@{push}`) equals its\n+worktree; its push destination (`<branch>@{push}`) equals its\n upstream (`<branch>@{upstream}`), so it cannot be distinguished\n-from a freshly pulled trunk that just looks \"fully merged\".\n+from a freshly pulled trunk that just looks \"fully merged\"; or\n+`branch.<name>.pruneMerged` is set to `false`.\n +\n Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 736480b002..e03805a8a7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -878,7 +878,9 @@ static int prune_merged_branches(const struct string_list *upstreams,\n \t\tstruct branch *branch = branch_get(short_name);\n \t\tconst char *upstream, *push;\n \t\tstruct strbuf full = STRBUF_INIT;\n+\t\tstruct strbuf key = STRBUF_INIT;\n \t\tint skip;\n+\t\tint opt_out;\n \n \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n \t\tskip = !!branch_checked_out(full.buf);\n@@ -893,6 +895,18 @@ static int prune_merged_branches(const struct string_list *upstreams,\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex beb86987ad..9e33179590 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1997,4 +1997,34 @@ test_expect_success '--prune-merged rejects positional arguments' '\n \ttest_grep \"does not take positional arguments\" err\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout remote add fork ../pm-fork &&\n+\ttest_config -C pm-optout remote.pushDefault fork &&\n+\ttest_config -C pm-optout push.default current &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544593","messageId":"1a0d5eab1598bec2b61a46578cfc8d3274398d35.1780477479.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v12 6/6] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-03T09:04:39Z","receivedAt":"2026-06-03T09:04:56Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --prune-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line per candidate, and\nexits without touching any ref.\n\nThe @{push}-vs-@{upstream} and unmerged filtering still applies,\nso the dry-run output is exactly the set that the live run would\ndelete.\n\n--dry-run is only meaningful in combination with --prune-merged\nand is rejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 12 +++++++---\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 60 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 69878549fc..c579df4fe0 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--prune-merged <branch>)...\n+git branch [--dry-run] (--prune-merged <branch>)...\n \n DESCRIPTION\n -----------\n@@ -230,6 +230,12 @@ Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\n you want them gone.\n \n+`--dry-run`::\n+\tWith `--prune-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex e03805a8a7..1811511b9e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -860,7 +860,7 @@ static void collect_forked_set(const struct string_list *upstreams,\n }\n \n static int prune_merged_branches(const struct string_list *upstreams,\n-\t\t\t\t int quiet)\n+\t\t\t\t int quiet, int dry_run)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n@@ -917,7 +917,7 @@ static int prune_merged_branches(const struct string_list *upstreams,\n \t\t\t\t      quiet,\n \t\t\t\t      1, /* warn_only */\n \t\t\t\t      1, /* no_head_fallback */\n-\t\t\t\t      0  /* dry_run */);\n+\t\t\t\t      dry_run);\n \n \tstrvec_clear(&deletable);\n \tstring_list_clear(&candidates, 0);\n@@ -967,6 +967,7 @@ int cmd_branch(int argc,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n \tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1024,6 +1025,8 @@ int cmd_branch(int argc,\n \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n \t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1083,6 +1086,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !prune_merged_upstreams.nr)\n+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1124,7 +1130,7 @@ int cmd_branch(int argc,\n \t\tif (argc)\n \t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n \t\t\t      \"repeat --prune-merged for each <branch>\"));\n-\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n+\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 9e33179590..29bfd0e109 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2027,4 +2027,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dry\" &&\n+\tgit clone pm-upstream pm-dry &&\n+\tgit -C pm-dry remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry remote.pushDefault fork &&\n+\ttest_config -C pm-dry push.default current &&\n+\tgit -C pm-dry branch one one-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-dry branch two two-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n+\ttest_grep \"Would delete branch one \" actual &&\n+\ttest_grep \"Would delete branch two \" actual &&\n+\n+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n+\tgit -C pm-dry rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n+\tgit clone pm-upstream pm-dry-mixed &&\n+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n+\ttest_config -C pm-dry-mixed push.default current &&\n+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dry-mixed local-only &&\n+\tgit -C pm-dry-mixed checkout - &&\n+\tgit -C pm-dry-mixed branch merged one-commit &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch merged\" out &&\n+\ttest_grep ! \"Would delete branch wip\" out &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run without --prune-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --prune-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"544782","messageId":"9f5c36a9-a3b8-403d-9c59-40367eb895bd@gmail.com","threadId":"65574","inReplyTo":"8834c424fbd27800636fe21ae73e9cdce75b558a.1780477479.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v12 1/6] branch: add --forked filter for --list mode","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-05T13:48:54Z","receivedAt":"2026-06-05T13:49:10Z","isPatch":true,"body":"Hi Harald\n\nOn 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add a --forked option to \"git branch\" list mode that keeps only\n> branches whose configured upstream matches <branch>. The argument\n> can be a ref (e.g. \"origin/main\", \"master\") or a shell-style\n> glob (e.g. \"origin/*\"). The option can be repeated to widen the\n> filter.\n\nDo we want to support a remote name as an alias for $remote/HEAD to \nmatch \"git checkout -b $remote\"?\n\n> Because it is a filter on list mode, --forked composes with the\n> existing list-mode filters, so\n> \n>      git branch --merged origin/main --forked 'origin/*'\n> \n> lists branches forked from origin that have already been\n> integrated into origin/main, and --no-merged inverts the question.\n\nNice\n> This is the building block for --prune-merged, which deletes the\n> listed branches once they have landed on their upstream.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc |   7 ++\n>   builtin/branch.c              | 147 +++++++++++++++++++++++++++++++++-\n>   ref-filter.c                  |  10 +--\n>   ref-filter.h                  |   2 +\n>   t/t3200-branch.sh             |  92 +++++++++++++++++++++\n>   5 files changed, 249 insertions(+), 9 deletions(-)\n> \n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index c0afddc424..8002d7f38c 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n>   \t   [--merged [<commit>]] [--no-merged [<commit>]]\n>   \t   [--contains [<commit>]] [--no-contains [<commit>]]\n>   \t   [--points-at <object>] [--format=<format>]\n> +\t   [(--forked <branch>)...]\n\nShould this come before --format? I think it logically belongs with \n--merged and --contains which also filter the output.\n\n>   \t   [(-r|--remotes) | (-a|--all)]\n>   \t   [--list] [<pattern>...]\n>   git branch [--track[=(direct|inherit)] | --no-track] [-f]\n> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.\n>   \tPrint the name of the current branch. In detached `HEAD` state,\n>   \tnothing is printed.\n>   \n> +`--forked <branch>`::\n> +\tList only branches whose configured upstream matches\n> +\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n> +\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n> +\toption can be repeated to widen the filter.\n\nThis is fine but do we want to add a sentence to the DESCRIPTION as well \nwhere it talks about \"--contains\" and \"--merged\"?\n\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1572a4f9ef..12711b29cf 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -28,9 +28,10 @@\n>   #include \"help.h\"\n>   #include \"advice.h\"\n>   #include \"commit-reach.h\"\n> +#include \"wildmatch.h\"\n>   \n>   static const char * const builtin_branch_usage[] = {\n> -\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n> +\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n>   \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n>   \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n>   \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n> @@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r\n>   \treturn strbuf_detach(&fmt, NULL);\n>   }\n>   \n> +static void filter_array_by_forked(struct ref_array *array,\n> +\t\t\t\t   const struct string_list *upstreams);\n\nWe try to avoid forward declarations unless they're really needed - can \nwe add the new functions up here instead?\n\n>   static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,\n> -\t\t\t   struct ref_format *format, struct string_list *output)\n> +\t\t\t   struct ref_format *format, struct string_list *output,\n> +\t\t\t   const struct string_list *forked_upstreams)\n>   {\n>   \tint i;\n>   \tstruct ref_array array;\n> @@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin\n>   \n>   \tfilter_refs(&array, filter, filter->kind);\n>   \n> +\tif (forked_upstreams->nr)\n> +\t\tfilter_array_by_forked(&array, forked_upstreams);\n\nThis gets a bit messy below where free elements when we filter \"array\". \nIt would be much nicer to do the filtering in apply_ref_filter() so that \nwe don't have to allocate those in the first place. I think it would \nmake it simpler to implement --prune-merged as collect_forked_set() \nwould become a call to filter_refs() and we could support --forked in \n\"git for-each-ref\".\n\n> +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n> +{\n> +\tstruct object_id oid;\n> +\tchar *full_ref = NULL;\n> +\n> +\tif (has_glob_specials(arg)) {\n> +\t\tout->name = xstrdup(arg);\n> +\t\tout->is_wildcard = 1;\n> +\t\treturn 0;\n> +\t}\n> +\n> +\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n> +\t\t\t  &full_ref, 0) == 1 &&\n> +\t    (starts_with(full_ref, \"refs/heads/\") ||\n> +\t     starts_with(full_ref, \"refs/remotes/\"))) {\n> +\t\tout->name = xstrdup(short_upstream_name(full_ref));\n\nI don't think abbreviating the refname here is a good idea as short \nnames are inherently ambiguous - in principle you could have a remote \ntracking branch and a local branch with the same short name. It also \nmeans we end up reconstructing the full name in a later patch, instead \nwe should just call short_upstream_name() where we need the abbreviated \nname.\n\n> +static int upstream_matches(const char *short_upstream,\n> +\t\t\t    const struct upstream_pattern *patterns,\n> +\t\t\t    size_t nr)\n> +{\n> +\tsize_t i;\n> +\n> +\tfor (i = 0; i < nr; i++) {\n> +\t\tconst struct upstream_pattern *p = &patterns[i];\n> +\t\tif (p->is_wildcard) {\n> +\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n> +\t\t\t\treturn 1;\n> +\t\t} else if (!strcmp(p->name, short_upstream)) {\n> +\t\t\treturn 1;\n> +\t\t}\n> +\t}\n\nThis is quadratic but maybe we can assume the user wont pass \"--forked\" \ntoo many times. If this ever becomes a problem we could use an strset \nfor the exact matches and then we only need to loop over the wildmatch \npatterns but we probably don't need to worry about that now.\n\n> +static int branch_upstream_matches(const char *full_refname,\n> +\t\t\t\t   const struct upstream_pattern *patterns,\n> +\t\t\t\t   size_t nr_patterns)\n> +{\n> +\tconst char *short_name;\n> +\tstruct branch *branch;\n> +\tconst char *upstream;\n> +\n> +\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n> +\t\treturn 0;\n> +\tbranch = branch_get(short_name);\n> +\tif (!branch)\n> +\t\treturn 0;\n> +\tupstream = branch_get_upstream(branch, NULL);\n> +\tif (!upstream)\n> +\t\treturn 0;\n> +\treturn upstream_matches(short_upstream_name(upstream),\n\nThis would be simpler if we matched on full names.\n\n> +static void filter_array_by_forked(struct ref_array *array,\n> +\t\t\t\t   const struct string_list *upstreams)\n> +{\n> +\tstruct upstream_pattern *patterns = NULL;\n> +\tsize_t nr_patterns = 0;\n> +\tint i, kept = 0;\n> +\n> +\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n> +\n> +\tfor (i = 0; i < array->nr; i++) {\n> +\t\tstruct ref_array_item *item = array->items[i];\n> +\t\tif (branch_upstream_matches(item->refname,\n> +\t\t\t\t\t    patterns, nr_patterns))\n> +\t\t\tarray->items[kept++] = item;\n> +\t\telse\n> +\t\t\tfree_ref_array_item(item);\n> +\t}\n> +\tarray->nr = kept;\n\nAs I said above this would be nicer if it was implemented in \napply_ref_filter().\n\n> @@ -714,6 +847,7 @@ int cmd_branch(int argc,\n>   \t/* possible actions */\n>   \tint delete = 0, rename = 0, copy = 0, list = 0,\n>   \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n> +\tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n\nPersonally I'd use a strvec here as we don't need the \"util\" member of \nthe string list but I'm probably biased as I don't really like the \nstring list api.\n\nI like the idea of making this just another filter to \"--list\". The \nbasics of the implementation look reasonable - it should be straight \nforward to match on full refs and move the relavent code into filter-refs.c\n\nThanks\n\nPhillip\n\n>   \tconst char *new_upstream = NULL;\n>   \tint noncreate_actions = 0;\n>   \t/* possible options */\n> @@ -767,6 +901,8 @@ int cmd_branch(int argc,\n>   \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n>   \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n>   \t\t\t N_(\"edit the description for the branch\")),\n> +\t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n> +\t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n>   \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>   \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>   \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -815,7 +951,8 @@ int cmd_branch(int argc,\n>   \t\tlist = 1;\n>   \n>   \tif (filter.with_commit || filter.no_commit ||\n> -\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n> +\t    filter.reachable_from || filter.unreachable_from ||\n> +\t    filter.points_at.nr || forked_upstreams.nr)\n>   \t\tlist = 1;\n>   \n>   \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n> @@ -880,7 +1017,8 @@ int cmd_branch(int argc,\n>   \t\tref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);\n>   \t\tref_sorting_set_sort_flags_all(\n>   \t\t\tsorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);\n> -\t\tprint_ref_list(&filter, sorting, &format, &output);\n> +\t\tprint_ref_list(&filter, sorting, &format, &output,\n> +\t\t\t       &forked_upstreams);\n>   \t\tprint_columns(&output, colopts, NULL);\n>   \t\tstring_list_clear(&output, 0);\n>   \t\tref_sorting_release(sorting);\n> @@ -1020,5 +1158,6 @@ int cmd_branch(int argc,\n>   \n>   out:\n>   \tstring_list_clear(&sorting_options, 0);\n> +\tstring_list_clear(&forked_upstreams, 0);\n>   \treturn ret;\n>   }\n> diff --git a/ref-filter.c b/ref-filter.c\n> index 1da4c0e60d..65e7bc6785 100644\n> --- a/ref-filter.c\n> +++ b/ref-filter.c\n> @@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)\n>   }\n>   \n>   /*  Free memory allocated for a ref_array_item */\n> -static void free_array_item(struct ref_array_item *item)\n> +void free_ref_array_item(struct ref_array_item *item)\n>   {\n>   \tfree((char *)item->symref);\n>   \tif (item->value) {\n> @@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)\n>   \n>   \tstrbuf_release(&output);\n>   \tstrbuf_release(&err);\n> -\tfree_array_item(item);\n> +\tfree_ref_array_item(item);\n>   \n>   \t/*\n>   \t * Increment the running count of refs that match the filter. If\n> @@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)\n>   \tint i;\n>   \n>   \tfor (i = 0; i < array->nr; i++)\n> -\t\tfree_array_item(array->items[i]);\n> +\t\tfree_ref_array_item(array->items[i]);\n>   \tFREE_AND_NULL(array->items);\n>   \tarray->nr = array->alloc = 0;\n>   \n> @@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,\n>   \t\tif (is_merged == include_reached)\n>   \t\t\tarray->items[array->nr++] = array->items[i];\n>   \t\telse\n> -\t\t\tfree_array_item(item);\n> +\t\t\tfree_ref_array_item(item);\n>   \t}\n>   \n>   \tclear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);\n> @@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,\n>   \n>   \tstrbuf_release(&err);\n>   \tstrbuf_release(&output);\n> -\tfree_array_item(ref_item);\n> +\tfree_ref_array_item(ref_item);\n>   }\n>   \n>   static int parse_sorting_atom(const char *atom)\n> diff --git a/ref-filter.h b/ref-filter.h\n> index 120221b47f..3883b9dc62 100644\n> --- a/ref-filter.h\n> +++ b/ref-filter.h\n> @@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,\n>   \t\t\t    struct ref_format *format);\n>   /*  Clear all memory allocated to ref_array */\n>   void ref_array_clear(struct ref_array *array);\n> +/*  Free a single item from a ref_array */\n> +void free_ref_array_item(struct ref_array_item *item);\n>   /*  Used to verify if the given format is correct and to parse out the used atoms */\n>   int verify_ref_format(struct ref_format *format);\n>   /*  Sort the given ref_array as per the ref_sorting provided */\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index e7829c2c4b..4e7deddc04 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--forked: setup' '\n> +\ttest_create_repo forked-upstream &&\n> +\ttest_commit -C forked-upstream base &&\n> +\tgit -C forked-upstream branch one base &&\n> +\tgit -C forked-upstream branch two base &&\n> +\n> +\ttest_create_repo forked-other &&\n> +\ttest_commit -C forked-other other-base &&\n> +\tgit -C forked-other branch foreign other-base &&\n> +\n> +\tgit clone forked-upstream forked &&\n> +\tgit -C forked remote add other ../forked-other &&\n> +\tgit -C forked fetch other &&\n> +\tgit -C forked branch local-base &&\n> +\tgit -C forked branch --track local-one origin/one &&\n> +\tgit -C forked branch --track local-two origin/two &&\n> +\tgit -C forked branch --track local-foreign other/foreign &&\n> +\tgit -C forked branch detached &&\n> +\tgit -C forked branch --track local-trunk local-base\n> +'\n> +\n> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n> +\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked <glob> filters by wildmatch' '\n> +\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-one\n> +\tlocal-two\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n> +\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n> +\techo local-trunk >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked can be repeated to widen the filter' '\n> +\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-one\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked combines literal and glob arguments' '\n> +\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-trunk\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n> +\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-one\n> +\tlocal-two\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked composes with --no-merged' '\n> +\ttest_when_finished \"git -C forked checkout detached\" &&\n> +\tgit -C forked checkout local-one &&\n> +\ttest_commit -C forked local-only &&\n> +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n> +\t\t--format=\"%(refname:short)\" >actual &&\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked rejects unknown branch/pattern' '\n> +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n> +\ttest_grep \"not a valid branch or pattern\" err\n> +'\n> +\n> +test_expect_success '--forked requires a value' '\n> +\ttest_must_fail git -C forked branch --forked 2>err &&\n> +\ttest_grep \"requires a value\" err\n> +'\n> +\n>   test_done\n\n"},{"id":"544783","messageId":"71675174-c477-420d-a656-d72dc7037d73@gmail.com","threadId":"65574","inReplyTo":"6c95e4e77cf555194b83eab92c7564e6b639f500.1780477479.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v12 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-05T13:49:21Z","receivedAt":"2026-06-05T13:49:37Z","isPatch":true,"body":"Hi Harald\n\nOn 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add a warn_only flag to delete_branches() and check_branch_commit()\n> so a bulk caller can report not-fully-merged branches as one-line\n> warnings and continue, instead of erroring with the four-line \"use\n> 'git branch -D'\" advice that the standalone \"git branch -d\" path\n> emits. Default callers pass 0 and are unaffected.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   builtin/branch.c | 26 +++++++++++++++++---------\n>   1 file changed, 17 insertions(+), 9 deletions(-)\n> \n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 12711b29cf..93d8eae891 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -192,7 +192,7 @@ static int branch_merged(int kind, const char *name,\n>   \n>   static int check_branch_commit(const char *branchname, const char *refname,\n>   \t\t\t       const struct object_id *oid, struct commit *head_rev,\n> -\t\t\t       int kinds, int force)\n> +\t\t\t       int kinds, int force, int warn_only)\n\nWe've already got two boolean parameters, lets replace those with an \n\"unsigned int flags\" parameter rather than adding a third. That way we \ncan avoid having to comment each argument as you do in a later patch.\n\nThanks\n\nPhillip\n\n>   {\n>   \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n>   \tif (!force && !rev) {\n> @@ -200,10 +200,16 @@ static int check_branch_commit(const char *branchname, const char *refname,\n>   \t\treturn -1;\n>   \t}\n>   \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n> -\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n> -\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> -\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> -\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\tif (warn_only) {\n> +\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t\tbranchname);\n> +\t\t} else {\n> +\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n> +\t\t\t      branchname);\n> +\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n> +\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n> +\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n> +\t\t}\n>   \t\treturn -1;\n>   \t}\n>   \treturn 0;\n> @@ -219,7 +225,7 @@ static void delete_branch_config(const char *branchname)\n>   }\n>   \n>   static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet)\n> +\t\t\t   int quiet, int warn_only)\n>   {\n>   \tstruct commit *head_rev = NULL;\n>   \tstruct object_id oid;\n> @@ -309,8 +315,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \n>   \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>   \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n> -\t\t\t\t\tforce)) {\n> -\t\t\tret = 1;\n> +\t\t\t\t\tforce, warn_only)) {\n> +\t\t\tif (!warn_only)\n> +\t\t\t\tret = 1;\n>   \t\t\tgoto next;\n>   \t\t}\n>   \n> @@ -995,7 +1002,8 @@ int cmd_branch(int argc,\n>   \tif (delete) {\n>   \t\tif (!argc)\n>   \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n> +\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n> +\t\t\t\t      quiet, 0);\n>   \t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n\n"},{"id":"544784","messageId":"734ce28a-d693-444b-b74b-2891e245de38@gmail.com","threadId":"65574","inReplyTo":"004a96f7a447ad8dcbcabeb36502330c2399f829.1780477479.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v12 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-05T13:49:51Z","receivedAt":"2026-06-05T13:50:06Z","isPatch":true,"body":"Hi Harald\n\nOn 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add no_head_fallback and dry_run flags to delete_branches() so a\n> bulk caller (the upcoming --prune-merged) can ask strictly about\n> merged-into-upstream without a silent fallback to HEAD, and\n> rehearse deletions with the same \"Would delete branch ...\" wording\n> as the live run. Existing callers pass 0 for both and keep current\n> behavior.\n> \n> When no_head_fallback is set, head_rev stays NULL through to\n> branch_merged(), whose \"merged to X but not yet merged to HEAD\"\n> reminder otherwise compares against HEAD. For the bulk caller\n> every candidate is known to have an upstream, so HEAD is\n> irrelevant. Guard the block on head_rev so the NULL case skips\n> it instead of treating \"NULL != reference_rev\" as \"diverges from\n> HEAD\" and emitting a spurious warning.\n\nSame comment as the last patch - use a flags argument rather than lots \nof individual booleans that make the call sites hard to read.\n\nThanks\n\nPhillip\n\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   builtin/branch.c | 27 +++++++++++++++++++--------\n>   1 file changed, 19 insertions(+), 8 deletions(-)\n> \n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 93d8eae891..09afdd9257 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -169,10 +169,13 @@ static int branch_merged(int kind, const char *name,\n>   \t * upstream, if any, otherwise with HEAD\", we should just\n>   \t * return the result of the repo_in_merge_bases() above without\n>   \t * any of the following code, but during the transition period,\n> -\t * a gentle reminder is in order.\n> +\t * a gentle reminder is in order.  Callers that opt out of the\n> +\t * HEAD fallback by passing head_rev=NULL are not interested in\n> +\t * the reminder either: they have already established that the\n> +\t * branch has an upstream, so HEAD is irrelevant to the decision.\n>   \t */\n> -\tif (head_rev != reference_rev) {\n> -\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n> +\tif (head_rev && head_rev != reference_rev) {\n> +\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n>   \t\tif (expect < 0)\n>   \t\t\texit(128);\n>   \t\tif (expect == merged)\n> @@ -225,7 +228,8 @@ static void delete_branch_config(const char *branchname)\n>   }\n>   \n>   static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet, int warn_only)\n> +\t\t\t   int quiet, int warn_only, int no_head_fallback,\n> +\t\t\t   int dry_run)\n>   {\n>   \tstruct commit *head_rev = NULL;\n>   \tstruct object_id oid;\n> @@ -259,7 +263,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t}\n>   \tbranch_name_pos = strcspn(fmt, \"%\");\n>   \n> -\tif (!force)\n> +\tif (!force && !no_head_fallback)\n>   \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n>   \n>   \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n> @@ -330,13 +334,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\tfree(target);\n>   \t}\n>   \n> -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n> +\tif (!dry_run &&\n> +\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n>   \t\tret = 1;\n>   \n>   \tfor_each_string_list_item(item, &refs_to_delete) {\n>   \t\tchar *describe_ref = item->util;\n>   \t\tchar *name = item->string;\n> -\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n> +\t\tif (dry_run) {\n> +\t\t\tif (!quiet)\n> +\t\t\t\tprintf(remote_branch\n> +\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n> +\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n> +\t\t\t\t\tname + branch_name_pos, describe_ref);\n> +\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n>   \t\t\tchar *refname = name + branch_name_pos;\n>   \t\t\tif (!quiet)\n>   \t\t\t\tprintf(remote_branch\n> @@ -1003,7 +1014,7 @@ int cmd_branch(int argc,\n>   \t\tif (!argc)\n>   \t\t\tdie(_(\"branch name required\"));\n>   \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n> -\t\t\t\t      quiet, 0);\n> +\t\t\t\t      quiet, 0, 0, 0);\n>   \t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n\n"},{"id":"544785","messageId":"629b38ab-42d8-4763-919f-005b6ada68a0@gmail.com","threadId":"65574","inReplyTo":"cccfdb831cc8c4ca0844d5b4ba2b70f0c801fc59.1780477479.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v12 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-05T13:50:15Z","receivedAt":"2026-06-05T13:50:30Z","isPatch":true,"body":"Hi Harald\n\nOn 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> \tgit branch --prune-merged <branch>...\n\nI'm not sure that synopsis is correct anymore as you need to repeat \n\"--prune-merged\". As --prune-merged now takes an argument there is no \nreason to forbid positional arguments so I think we should support\n\n\tgit branch --prune-merged origin/master 'feature*'\n\nto delete all the branches beginning with \"feature\" that have the \nupstream \"origin/master\" and have been merged.\n\nI wonder about the name - the other options that delete branches are \ncalled \"delete\", not \"prune\". Also \"--prune-merged\" does not delete the \nbranches listed by \"--merged\" so maybe \"--delete-forked\" would be better?\n\nI've not commented in detail on the code as it will need to change a bit \nonce we match on full refnames and do the filtering in \napply_ref_filter() but I think the basics are sound.\n\nI'll stop here - I did quickly scan the next two patches and they both \nlooked like sensible ideas.\n\nThanks\n\nPhillip\n\n> deletes the local branches that \"--forked <branch>\" would list,\n> restricted to those whose tip is reachable from their configured\n> upstream: the work has already landed on the upstream they track,\n> so the local copy is no longer needed.\n> \n> Reachability is read from local refs; nothing is fetched. Users\n> who want fresh upstream refs run \"git fetch\" first.\n> \n> Three classes of branches are spared:\n> \n>    * any branch checked out in any worktree;\n>    * any branch whose upstream no longer resolves locally (its\n>      disappearance is not, on its own, evidence of integration);\n>    * any branch whose push destination equals its upstream\n>      (<branch>@{push} == <branch>@{upstream}). Such a branch\n>      cannot be distinguished from a freshly pulled trunk that\n>      just looks \"fully merged\", e.g. local \"main\" tracking and\n>      pushing to \"origin/main\" right after a pull. Only branches\n>      that push somewhere other than their upstream (typically\n>      topics in a fork-based workflow) are treated as candidates.\n> \n> Deletion goes through the existing delete_branches() in warn-only\n> mode and with the HEAD-fallback disabled: a branch that is not\n> yet fully merged to its upstream is reported as a one-line warning\n> and skipped, so a single un-mergeable topic does not abort the\n> whole sweep. We only act on upstream-merged status.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc |  23 +++++\n>   builtin/branch.c              | 117 +++++++++++++++++++--\n>   t/t3200-branch.sh             | 188 ++++++++++++++++++++++++++++++++++\n>   3 files changed, 318 insertions(+), 10 deletions(-)\n> \n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index 8002d7f38c..f7942fcd7d 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n>   git branch (-c|-C) [<old-branch>] <new-branch>\n>   git branch (-d|-D) [-r] <branch-name>...\n>   git branch --edit-description [<branch-name>]\n> +git branch (--prune-merged <branch>)...\n>   \n>   DESCRIPTION\n>   -----------\n> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.\n>   \t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n>   \toption can be repeated to widen the filter.\n>   \n> +`--prune-merged <branch>`::\n> +\tDelete the local branches that `--forked` would list for the\n> +\tsame _<branch>_, but only those whose tip is reachable from\n> +\ttheir configured upstream. In other words, the work on the\n> +\tbranch has already landed on the upstream it tracks, so the\n> +\tlocal copy is no longer needed. May be given more than once to\n> +\tunion the matches; positional arguments are not accepted.\n> ++\n> +Reachability is checked against whatever the upstream refs say\n> +locally; nothing is fetched. Run `git fetch` first if you want\n> +the upstream refs refreshed.\n> ++\n> +A branch is left alone if any of the following holds:\n> +its upstream no longer resolves locally; it is checked out in any\n> +worktree; or its push destination (`<branch>@{push}`) equals its\n> +upstream (`<branch>@{upstream}`), so it cannot be distinguished\n> +from a freshly pulled trunk that just looks \"fully merged\".\n> ++\n> +Branches refused by the \"fully merged\" safety check are listed as\n> +warnings and skipped; pass them to `git branch -D` explicitly if\n> +you want them gone.\n> +\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 09afdd9257..736480b002 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n>   \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n> +\tN_(\"git branch [<options>] (--prune-merged <branch>)...\"),\n>   \tNULL\n>   };\n>   \n> @@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,\n>   \treturn 0;\n>   }\n>   \n> -static int branch_upstream_matches(const char *full_refname,\n> +static int branch_upstream_matches(const char *short_branch_name,\n>   \t\t\t\t   const struct upstream_pattern *patterns,\n>   \t\t\t\t   size_t nr_patterns)\n>   {\n> -\tconst char *short_name;\n> -\tstruct branch *branch;\n> +\tstruct branch *branch = branch_get(short_branch_name);\n>   \tconst char *upstream;\n>   \n> -\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n> -\t\treturn 0;\n> -\tbranch = branch_get(short_name);\n>   \tif (!branch)\n>   \t\treturn 0;\n>   \tupstream = branch_get_upstream(branch, NULL);\n> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,\n>   \n>   \tfor (i = 0; i < array->nr; i++) {\n>   \t\tstruct ref_array_item *item = array->items[i];\n> -\t\tif (branch_upstream_matches(item->refname,\n> -\t\t\t\t\t    patterns, nr_patterns))\n> +\t\tconst char *short_name;\n> +\t\tif (skip_prefix(item->refname, \"refs/heads/\", &short_name) &&\n> +\t\t    branch_upstream_matches(short_name, patterns, nr_patterns))\n>   \t\t\tarray->items[kept++] = item;\n>   \t\telse\n>   \t\t\tfree_ref_array_item(item);\n> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,\n>   \tupstream_pattern_list_clear(patterns, nr_patterns);\n>   }\n>   \n> +struct forked_cb {\n> +\tconst struct upstream_pattern *patterns;\n> +\tsize_t nr_patterns;\n> +\tstruct string_list *out;\n> +};\n> +\n> +static int collect_forked_branch(const struct reference *ref, void *cb_data)\n> +{\n> +\tstruct forked_cb *cb = cb_data;\n> +\n> +\tif (ref->flags & REF_ISSYMREF)\n> +\t\treturn 0;\n> +\tif (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))\n> +\t\tstring_list_append(cb->out, ref->name);\n> +\treturn 0;\n> +}\n> +\n> +static void collect_forked_set(const struct string_list *upstreams,\n> +\t\t\t       struct string_list *out)\n> +{\n> +\tstruct upstream_pattern *patterns = NULL;\n> +\tsize_t nr_patterns = 0;\n> +\tstruct forked_cb cb;\n> +\n> +\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n> +\tcb.patterns = patterns;\n> +\tcb.nr_patterns = nr_patterns;\n> +\tcb.out = out;\n> +\n> +\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n> +\t\t\t\t collect_forked_branch, &cb);\n> +\n> +\tstring_list_sort(out);\n> +\n> +\tupstream_pattern_list_clear(patterns, nr_patterns);\n> +}\n> +\n> +static int prune_merged_branches(const struct string_list *upstreams,\n> +\t\t\t\t int quiet)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n> +\tstruct strvec deletable = STRVEC_INIT;\n> +\tstruct string_list_item *item;\n> +\tint ret = 0;\n> +\n> +\tif (!upstreams->nr)\n> +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n> +\n> +\tcollect_forked_set(upstreams, &candidates);\n> +\n> +\tfor_each_string_list_item(item, &candidates) {\n> +\t\tconst char *short_name = item->string;\n> +\t\tstruct branch *branch = branch_get(short_name);\n> +\t\tconst char *upstream, *push;\n> +\t\tstruct strbuf full = STRBUF_INIT;\n> +\t\tint skip;\n> +\n> +\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n> +\t\tskip = !!branch_checked_out(full.buf);\n> +\t\tstrbuf_release(&full);\n> +\t\tif (skip)\n> +\t\t\tcontinue;\n> +\n> +\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n> +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n> +\t\t\tcontinue;\n> +\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n> +\t\tif (!push || !strcmp(push, upstream))\n> +\t\t\tcontinue;\n> +\n> +\t\tstrvec_push(&deletable, short_name);\n> +\t}\n> +\n> +\tif (deletable.nr)\n> +\t\tret = delete_branches(deletable.nr, deletable.v,\n> +\t\t\t\t      0, /* force */\n> +\t\t\t\t      FILTER_REFS_BRANCHES,\n> +\t\t\t\t      quiet,\n> +\t\t\t\t      1, /* warn_only */\n> +\t\t\t\t      1, /* no_head_fallback */\n> +\t\t\t\t      0  /* dry_run */);\n> +\n> +\tstrvec_clear(&deletable);\n> +\tstring_list_clear(&candidates, 0);\n> +\treturn ret;\n> +}\n> +\n>   static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n>   \n>   static int edit_branch_description(const char *branch_name)\n> @@ -866,6 +952,7 @@ int cmd_branch(int argc,\n>   \tint delete = 0, rename = 0, copy = 0, list = 0,\n>   \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>   \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n> +\tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n>   \tconst char *new_upstream = NULL;\n>   \tint noncreate_actions = 0;\n>   \t/* possible options */\n> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,\n>   \t\t\t N_(\"edit the description for the branch\")),\n>   \t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n>   \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n> +\t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n> +\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n>   \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>   \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>   \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,\n>   \t\t\t     0);\n>   \n>   \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n> -\t    !show_current && !unset_upstream && argc == 0)\n> +\t    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&\n> +\t    argc == 0)\n>   \t\tlist = 1;\n>   \n>   \tif (filter.with_commit || filter.no_commit ||\n> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,\n>   \n>   \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>   \t\t\t    !!show_current + !!list + !!edit_description +\n> -\t\t\t    !!unset_upstream;\n> +\t\t\t    !!unset_upstream + !!prune_merged_upstreams.nr;\n>   \tif (noncreate_actions > 1)\n>   \t\tusage_with_options(builtin_branch_usage, options);\n>   \n> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,\n>   \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n>   \t\t\t\t      quiet, 0, 0, 0);\n>   \t\tgoto out;\n> +\t} else if (prune_merged_upstreams.nr) {\n> +\t\tif (argc)\n> +\t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n> +\t\t\t      \"repeat --prune-merged for each <branch>\"));\n> +\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n> +\t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n>   \t\tret = 0;\n> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,\n>   out:\n>   \tstring_list_clear(&sorting_options, 0);\n>   \tstring_list_clear(&forked_upstreams, 0);\n> +\tstring_list_clear(&prune_merged_upstreams, 0);\n>   \treturn ret;\n>   }\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 4e7deddc04..beb86987ad 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '\n>   \ttest_grep \"requires a value\" err\n>   '\n>   \n> +test_expect_success '--prune-merged: setup' '\n> +\ttest_create_repo pm-upstream &&\n> +\ttest_commit -C pm-upstream base &&\n> +\tgit -C pm-upstream checkout -b next &&\n> +\ttest_commit -C pm-upstream one-commit &&\n> +\ttest_commit -C pm-upstream two-commit &&\n> +\tgit -C pm-upstream branch one HEAD~ &&\n> +\tgit -C pm-upstream branch two HEAD &&\n> +\tgit -C pm-upstream branch wip main &&\n> +\tgit -C pm-upstream checkout main &&\n> +\ttest_create_repo pm-fork\n> +'\n> +\n> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> +\ttest_when_finished \"rm -rf pm-merged\" &&\n> +\tgit clone pm-upstream pm-merged &&\n> +\tgit -C pm-merged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-merged remote.pushDefault fork &&\n> +\ttest_config -C pm-merged push.default current &&\n> +\tgit -C pm-merged branch one one-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-merged branch two two-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n> +\n> +\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged accepts a literal upstream' '\n> +\ttest_when_finished \"rm -rf pm-literal\" &&\n> +\tgit clone pm-upstream pm-literal &&\n> +\tgit -C pm-literal remote add fork ../pm-fork &&\n> +\ttest_config -C pm-literal remote.pushDefault fork &&\n> +\ttest_config -C pm-literal push.default current &&\n> +\tgit -C pm-literal branch one one-commit &&\n> +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-literal branch --prune-merged origin/next &&\n> +\n> +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-union\" &&\n> +\tgit clone pm-upstream pm-union &&\n> +\tgit -C pm-union remote add fork ../pm-fork &&\n> +\ttest_config -C pm-union remote.pushDefault fork &&\n> +\ttest_config -C pm-union push.default current &&\n> +\tgit -C pm-union branch one one-commit &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-union branch two base &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n> +\tgit -C pm-union checkout --detach &&\n> +\n> +\tgit -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&\n> +\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged accepts a local upstream' '\n> +\ttest_when_finished \"rm -rf pm-local\" &&\n> +\tgit clone pm-upstream pm-local &&\n> +\tgit -C pm-local remote add fork ../pm-fork &&\n> +\ttest_config -C pm-local remote.pushDefault fork &&\n> +\ttest_config -C pm-local push.default current &&\n> +\tgit -C pm-local checkout -b trunk &&\n> +\tgit -C pm-local branch one one-commit &&\n> +\tgit -C pm-local branch --set-upstream-to=trunk one &&\n> +\tgit -C pm-local merge --ff-only one-commit &&\n> +\n> +\tgit -C pm-local branch --prune-merged trunk &&\n> +\n> +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n> +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n> +\tgit clone pm-upstream pm-unmerged &&\n> +\tgit -C pm-unmerged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-unmerged remote.pushDefault fork &&\n> +\ttest_config -C pm-unmerged push.default current &&\n> +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n> +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-unmerged local-only &&\n> +\tgit -C pm-unmerged checkout - &&\n> +\n> +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n> +\ttest_grep \"not fully merged\" err &&\n> +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n> +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n> +'\n> +\n> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n> +\ttest_when_finished \"rm -rf pm-nohead\" &&\n> +\tgit clone pm-upstream pm-nohead &&\n> +\tgit -C pm-nohead remote add fork ../pm-fork &&\n> +\ttest_config -C pm-nohead remote.pushDefault fork &&\n> +\ttest_config -C pm-nohead push.default current &&\n> +\tgit -C pm-nohead branch topic one-commit &&\n> +\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n> +\n> +\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n> +\n> +\ttest_grep ! \"not yet merged to HEAD\" err &&\n> +\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n> +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n> +\tgit clone pm-upstream pm-upstream-gone &&\n> +\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n> +\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n> +\ttest_config -C pm-upstream-gone push.default current &&\n> +\tgit -C pm-upstream-gone branch one one-commit &&\n> +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n> +\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged never deletes the checked-out branch' '\n> +\ttest_when_finished \"rm -rf pm-head\" &&\n> +\tgit clone pm-upstream pm-head &&\n> +\tgit -C pm-head remote add fork ../pm-fork &&\n> +\ttest_config -C pm-head remote.pushDefault fork &&\n> +\ttest_config -C pm-head push.default current &&\n> +\tgit -C pm-head checkout -b one one-commit &&\n> +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-head rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged spares branches that push back to their upstream' '\n> +\ttest_when_finished \"rm -rf pm-push-eq\" &&\n> +\tgit clone pm-upstream pm-push-eq &&\n> +\tgit -C pm-push-eq checkout --detach &&\n> +\n> +\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n> +\ttest_when_finished \"rm -rf pm-push-branch\" &&\n> +\tgit clone pm-upstream pm-push-branch &&\n> +\tgit -C pm-push-branch remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-branch remote.pushDefault fork &&\n> +\ttest_config -C pm-push-branch push.default current &&\n> +\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n> +\tgit -C pm-push-branch checkout --detach &&\n> +\n> +\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n> +\ttest_when_finished \"rm -rf pm-push-diff\" &&\n> +\tgit clone pm-upstream pm-push-diff &&\n> +\tgit -C pm-push-diff remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-diff remote.pushDefault fork &&\n> +\ttest_config -C pm-push-diff push.default current &&\n> +\tgit -C pm-push-diff branch topic one-commit &&\n> +\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n> +\tgit -C pm-push-diff checkout --detach &&\n> +\n> +\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged requires a value' '\n> +\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n> +\ttest_grep \"requires a value\" err\n> +'\n> +\n> +test_expect_success '--prune-merged rejects positional arguments' '\n> +\ttest_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&\n> +\ttest_grep \"does not take positional arguments\" err\n> +'\n> +\n>   test_done\n\n"},{"id":"544793","messageId":"fd488dae-bd26-4819-8972-cba2c787ad14@gmail.com","threadId":"65574","inReplyTo":"629b38ab-42d8-4763-919f-005b6ada68a0@gmail.com","subject":"Re: [PATCH v12 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-05T15:04:20Z","receivedAt":"2026-06-05T15:04:35Z","isPatch":true,"body":"On 05/06/2026 14:50, Phillip Wood wrote:\n> \n> I wonder about the name - the other options that delete branches are \n> called \"delete\", not \"prune\". Also \"--prune-merged\" does not delete the \n> branches listed by \"--merged\" so maybe \"--delete-forked\" would be better?\n\n\"delete-forked\" doesn't capture the fact the branch has been merged \nthough - I wonder if anyone has a better idea\n\nThanks\n\nPhillip\n> I've not commented in detail on the code as it will need to change a bit \n> once we match on full refnames and do the filtering in \n> apply_ref_filter() but I think the basics are sound.\n> \n> I'll stop here - I did quickly scan the next two patches and they both \n> looked like sensible ideas.\n> \n> Thanks\n> \n> Phillip\n> \n>> deletes the local branches that \"--forked <branch>\" would list,\n>> restricted to those whose tip is reachable from their configured\n>> upstream: the work has already landed on the upstream they track,\n>> so the local copy is no longer needed.\n>>\n>> Reachability is read from local refs; nothing is fetched. Users\n>> who want fresh upstream refs run \"git fetch\" first.\n>>\n>> Three classes of branches are spared:\n>>\n>>    * any branch checked out in any worktree;\n>>    * any branch whose upstream no longer resolves locally (its\n>>      disappearance is not, on its own, evidence of integration);\n>>    * any branch whose push destination equals its upstream\n>>      (<branch>@{push} == <branch>@{upstream}). Such a branch\n>>      cannot be distinguished from a freshly pulled trunk that\n>>      just looks \"fully merged\", e.g. local \"main\" tracking and\n>>      pushing to \"origin/main\" right after a pull. Only branches\n>>      that push somewhere other than their upstream (typically\n>>      topics in a fork-based workflow) are treated as candidates.\n>>\n>> Deletion goes through the existing delete_branches() in warn-only\n>> mode and with the HEAD-fallback disabled: a branch that is not\n>> yet fully merged to its upstream is reported as a one-line warning\n>> and skipped, so a single un-mergeable topic does not abort the\n>> whole sweep. We only act on upstream-merged status.\n>>\n>> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>> ---\n>>   Documentation/git-branch.adoc |  23 +++++\n>>   builtin/branch.c              | 117 +++++++++++++++++++--\n>>   t/t3200-branch.sh             | 188 ++++++++++++++++++++++++++++++++++\n>>   3 files changed, 318 insertions(+), 10 deletions(-)\n>>\n>> diff --git a/Documentation/git-branch.adoc b/Documentation/git- \n>> branch.adoc\n>> index 8002d7f38c..f7942fcd7d 100644\n>> --- a/Documentation/git-branch.adoc\n>> +++ b/Documentation/git-branch.adoc\n>> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n>>   git branch (-c|-C) [<old-branch>] <new-branch>\n>>   git branch (-d|-D) [-r] <branch-name>...\n>>   git branch --edit-description [<branch-name>]\n>> +git branch (--prune-merged <branch>)...\n>>   DESCRIPTION\n>>   -----------\n>> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.\n>>       `master`) or a shell-style glob (e.g. `'origin/*'`). The\n>>       option can be repeated to widen the filter.\n>> +`--prune-merged <branch>`::\n>> +    Delete the local branches that `--forked` would list for the\n>> +    same _<branch>_, but only those whose tip is reachable from\n>> +    their configured upstream. In other words, the work on the\n>> +    branch has already landed on the upstream it tracks, so the\n>> +    local copy is no longer needed. May be given more than once to\n>> +    union the matches; positional arguments are not accepted.\n>> ++\n>> +Reachability is checked against whatever the upstream refs say\n>> +locally; nothing is fetched. Run `git fetch` first if you want\n>> +the upstream refs refreshed.\n>> ++\n>> +A branch is left alone if any of the following holds:\n>> +its upstream no longer resolves locally; it is checked out in any\n>> +worktree; or its push destination (`<branch>@{push}`) equals its\n>> +upstream (`<branch>@{upstream}`), so it cannot be distinguished\n>> +from a freshly pulled trunk that just looks \"fully merged\".\n>> ++\n>> +Branches refused by the \"fully merged\" safety check are listed as\n>> +warnings and skipped; pass them to `git branch -D` explicitly if\n>> +you want them gone.\n>> +\n>>   `-v`::\n>>   `-vv`::\n>>   `--verbose`::\n>> diff --git a/builtin/branch.c b/builtin/branch.c\n>> index 09afdd9257..736480b002 100644\n>> --- a/builtin/branch.c\n>> +++ b/builtin/branch.c\n>> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n>>       N_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>>       N_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>>       N_(\"git branch [<options>] [-r | -a] [--format]\"),\n>> +    N_(\"git branch [<options>] (--prune-merged <branch>)...\"),\n>>       NULL\n>>   };\n>> @@ -782,17 +783,13 @@ static int upstream_matches(const char \n>> *short_upstream,\n>>       return 0;\n>>   }\n>> -static int branch_upstream_matches(const char *full_refname,\n>> +static int branch_upstream_matches(const char *short_branch_name,\n>>                      const struct upstream_pattern *patterns,\n>>                      size_t nr_patterns)\n>>   {\n>> -    const char *short_name;\n>> -    struct branch *branch;\n>> +    struct branch *branch = branch_get(short_branch_name);\n>>       const char *upstream;\n>> -    if (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n>> -        return 0;\n>> -    branch = branch_get(short_name);\n>>       if (!branch)\n>>           return 0;\n>>       upstream = branch_get_upstream(branch, NULL);\n>> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct \n>> ref_array *array,\n>>       for (i = 0; i < array->nr; i++) {\n>>           struct ref_array_item *item = array->items[i];\n>> -        if (branch_upstream_matches(item->refname,\n>> -                        patterns, nr_patterns))\n>> +        const char *short_name;\n>> +        if (skip_prefix(item->refname, \"refs/heads/\", &short_name) &&\n>> +            branch_upstream_matches(short_name, patterns, nr_patterns))\n>>               array->items[kept++] = item;\n>>           else\n>>               free_ref_array_item(item);\n>> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct \n>> ref_array *array,\n>>       upstream_pattern_list_clear(patterns, nr_patterns);\n>>   }\n>> +struct forked_cb {\n>> +    const struct upstream_pattern *patterns;\n>> +    size_t nr_patterns;\n>> +    struct string_list *out;\n>> +};\n>> +\n>> +static int collect_forked_branch(const struct reference *ref, void \n>> *cb_data)\n>> +{\n>> +    struct forked_cb *cb = cb_data;\n>> +\n>> +    if (ref->flags & REF_ISSYMREF)\n>> +        return 0;\n>> +    if (branch_upstream_matches(ref->name, cb->patterns, cb- \n>> >nr_patterns))\n>> +        string_list_append(cb->out, ref->name);\n>> +    return 0;\n>> +}\n>> +\n>> +static void collect_forked_set(const struct string_list *upstreams,\n>> +                   struct string_list *out)\n>> +{\n>> +    struct upstream_pattern *patterns = NULL;\n>> +    size_t nr_patterns = 0;\n>> +    struct forked_cb cb;\n>> +\n>> +    parse_forked_args(upstreams, &patterns, &nr_patterns);\n>> +    cb.patterns = patterns;\n>> +    cb.nr_patterns = nr_patterns;\n>> +    cb.out = out;\n>> +\n>> +    refs_for_each_branch_ref(get_main_ref_store(the_repository),\n>> +                 collect_forked_branch, &cb);\n>> +\n>> +    string_list_sort(out);\n>> +\n>> +    upstream_pattern_list_clear(patterns, nr_patterns);\n>> +}\n>> +\n>> +static int prune_merged_branches(const struct string_list *upstreams,\n>> +                 int quiet)\n>> +{\n>> +    struct ref_store *refs = get_main_ref_store(the_repository);\n>> +    struct string_list candidates = STRING_LIST_INIT_DUP;\n>> +    struct strvec deletable = STRVEC_INIT;\n>> +    struct string_list_item *item;\n>> +    int ret = 0;\n>> +\n>> +    if (!upstreams->nr)\n>> +        die(_(\"--prune-merged requires at least one <branch>\"));\n>> +\n>> +    collect_forked_set(upstreams, &candidates);\n>> +\n>> +    for_each_string_list_item(item, &candidates) {\n>> +        const char *short_name = item->string;\n>> +        struct branch *branch = branch_get(short_name);\n>> +        const char *upstream, *push;\n>> +        struct strbuf full = STRBUF_INIT;\n>> +        int skip;\n>> +\n>> +        strbuf_addf(&full, \"refs/heads/%s\", short_name);\n>> +        skip = !!branch_checked_out(full.buf);\n>> +        strbuf_release(&full);\n>> +        if (skip)\n>> +            continue;\n>> +\n>> +        upstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n>> +        if (!upstream || !refs_ref_exists(refs, upstream))\n>> +            continue;\n>> +        push = branch ? branch_get_push(branch, NULL) : NULL;\n>> +        if (!push || !strcmp(push, upstream))\n>> +            continue;\n>> +\n>> +        strvec_push(&deletable, short_name);\n>> +    }\n>> +\n>> +    if (deletable.nr)\n>> +        ret = delete_branches(deletable.nr, deletable.v,\n>> +                      0, /* force */\n>> +                      FILTER_REFS_BRANCHES,\n>> +                      quiet,\n>> +                      1, /* warn_only */\n>> +                      1, /* no_head_fallback */\n>> +                      0  /* dry_run */);\n>> +\n>> +    strvec_clear(&deletable);\n>> +    string_list_clear(&candidates, 0);\n>> +    return ret;\n>> +}\n>> +\n>>   static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n>>   static int edit_branch_description(const char *branch_name)\n>> @@ -866,6 +952,7 @@ int cmd_branch(int argc,\n>>       int delete = 0, rename = 0, copy = 0, list = 0,\n>>           unset_upstream = 0, show_current = 0, edit_description = 0;\n>>       struct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n>> +    struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n>>       const char *new_upstream = NULL;\n>>       int noncreate_actions = 0;\n>>       /* possible options */\n>> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,\n>>                N_(\"edit the description for the branch\")),\n>>           OPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n>>               N_(\"list local branches whose upstream matches <branch> \n>> (repeatable)\")),\n>> +        OPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, \n>> N_(\"branch\"),\n>> +            N_(\"delete local branches whose upstream matches <branch> \n>> and is merged (repeatable)\")),\n>>           OPT__FORCE(&force, N_(\"force creation, move/rename, \n>> deletion\"), PARSE_OPT_NOCOMPLETE),\n>>           OPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>>           OPT_NO_MERGED(&filter, N_(\"print only branches that are not \n>> merged\")),\n>> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,\n>>                    0);\n>>       if (!delete && !rename && !copy && !edit_description && ! \n>> new_upstream &&\n>> -        !show_current && !unset_upstream && argc == 0)\n>> +        !show_current && !unset_upstream && ! \n>> prune_merged_upstreams.nr &&\n>> +        argc == 0)\n>>           list = 1;\n>>       if (filter.with_commit || filter.no_commit ||\n>> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,\n>>       noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>>                   !!show_current + !!list + !!edit_description +\n>> -                !!unset_upstream;\n>> +                !!unset_upstream + !!prune_merged_upstreams.nr;\n>>       if (noncreate_actions > 1)\n>>           usage_with_options(builtin_branch_usage, options);\n>> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,\n>>           ret = delete_branches(argc, argv, delete > 1, filter.kind,\n>>                         quiet, 0, 0, 0);\n>>           goto out;\n>> +    } else if (prune_merged_upstreams.nr) {\n>> +        if (argc)\n>> +            die(_(\"--prune-merged does not take positional arguments; \"\n>> +                  \"repeat --prune-merged for each <branch>\"));\n>> +        ret = prune_merged_branches(&prune_merged_upstreams, quiet);\n>> +        goto out;\n>>       } else if (show_current) {\n>>           print_current_branch_name();\n>>           ret = 0;\n>> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,\n>>   out:\n>>       string_list_clear(&sorting_options, 0);\n>>       string_list_clear(&forked_upstreams, 0);\n>> +    string_list_clear(&prune_merged_upstreams, 0);\n>>       return ret;\n>>   }\n>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n>> index 4e7deddc04..beb86987ad 100755\n>> --- a/t/t3200-branch.sh\n>> +++ b/t/t3200-branch.sh\n>> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '\n>>       test_grep \"requires a value\" err\n>>   '\n>> +test_expect_success '--prune-merged: setup' '\n>> +    test_create_repo pm-upstream &&\n>> +    test_commit -C pm-upstream base &&\n>> +    git -C pm-upstream checkout -b next &&\n>> +    test_commit -C pm-upstream one-commit &&\n>> +    test_commit -C pm-upstream two-commit &&\n>> +    git -C pm-upstream branch one HEAD~ &&\n>> +    git -C pm-upstream branch two HEAD &&\n>> +    git -C pm-upstream branch wip main &&\n>> +    git -C pm-upstream checkout main &&\n>> +    test_create_repo pm-fork\n>> +'\n>> +\n>> +test_expect_success '--prune-merged deletes branches integrated into \n>> upstream' '\n>> +    test_when_finished \"rm -rf pm-merged\" &&\n>> +    git clone pm-upstream pm-merged &&\n>> +    git -C pm-merged remote add fork ../pm-fork &&\n>> +    test_config -C pm-merged remote.pushDefault fork &&\n>> +    test_config -C pm-merged push.default current &&\n>> +    git -C pm-merged branch one one-commit &&\n>> +    git -C pm-merged branch --set-upstream-to=origin/next one &&\n>> +    git -C pm-merged branch two two-commit &&\n>> +    git -C pm-merged branch --set-upstream-to=origin/next two &&\n>> +\n>> +    git -C pm-merged branch --prune-merged \"origin/*\" &&\n>> +\n>> +    test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n>> +    test_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n>> +'\n>> +\n>> +test_expect_success '--prune-merged accepts a literal upstream' '\n>> +    test_when_finished \"rm -rf pm-literal\" &&\n>> +    git clone pm-upstream pm-literal &&\n>> +    git -C pm-literal remote add fork ../pm-fork &&\n>> +    test_config -C pm-literal remote.pushDefault fork &&\n>> +    test_config -C pm-literal push.default current &&\n>> +    git -C pm-literal branch one one-commit &&\n>> +    git -C pm-literal branch --set-upstream-to=origin/next one &&\n>> +\n>> +    git -C pm-literal branch --prune-merged origin/next &&\n>> +\n>> +    test_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n>> +'\n>> +\n>> +test_expect_success '--prune-merged unions multiple <branch> \n>> arguments' '\n>> +    test_when_finished \"rm -rf pm-union\" &&\n>> +    git clone pm-upstream pm-union &&\n>> +    git -C pm-union remote add fork ../pm-fork &&\n>> +    test_config -C pm-union remote.pushDefault fork &&\n>> +    test_config -C pm-union push.default current &&\n>> +    git -C pm-union branch one one-commit &&\n>> +    git -C pm-union branch --set-upstream-to=origin/next one &&\n>> +    git -C pm-union branch two base &&\n>> +    git -C pm-union branch --set-upstream-to=origin/main two &&\n>> +    git -C pm-union checkout --detach &&\n>> +\n>> +    git -C pm-union branch --prune-merged origin/next --prune-merged \n>> origin/main &&\n>> +\n>> +    test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n>> +    test_must_fail git -C pm-union rev-parse --verify refs/heads/two\n>> +'\n>> +\n>> +test_expect_success '--prune-merged accepts a local upstream' '\n>> +    test_when_finished \"rm -rf pm-local\" &&\n>> +    git clone pm-upstream pm-local &&\n>> +    git -C pm-local remote add fork ../pm-fork &&\n>> +    test_config -C pm-local remote.pushDefault fork &&\n>> +    test_config -C pm-local push.default current &&\n>> +    git -C pm-local checkout -b trunk &&\n>> +    git -C pm-local branch one one-commit &&\n>> +    git -C pm-local branch --set-upstream-to=trunk one &&\n>> +    git -C pm-local merge --ff-only one-commit &&\n>> +\n>> +    git -C pm-local branch --prune-merged trunk &&\n>> +\n>> +    test_must_fail git -C pm-local rev-parse --verify refs/heads/one\n>> +'\n>> +\n>> +test_expect_success '--prune-merged warns instead of erroring on un- \n>> integrated commits' '\n>> +    test_when_finished \"rm -rf pm-unmerged\" &&\n>> +    git clone pm-upstream pm-unmerged &&\n>> +    git -C pm-unmerged remote add fork ../pm-fork &&\n>> +    test_config -C pm-unmerged remote.pushDefault fork &&\n>> +    test_config -C pm-unmerged push.default current &&\n>> +    git -C pm-unmerged checkout -b wip origin/wip &&\n>> +    git -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n>> +    test_commit -C pm-unmerged local-only &&\n>> +    git -C pm-unmerged checkout - &&\n>> +\n>> +    git -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n>> +    test_grep \"not fully merged\" err &&\n>> +    test_grep ! \"If you are sure you want to delete it\" err &&\n>> +    git -C pm-unmerged rev-parse --verify refs/heads/wip\n>> +'\n>> +\n>> +test_expect_success '--prune-merged is silent about not-merged-to- \n>> HEAD' '\n>> +    test_when_finished \"rm -rf pm-nohead\" &&\n>> +    git clone pm-upstream pm-nohead &&\n>> +    git -C pm-nohead remote add fork ../pm-fork &&\n>> +    test_config -C pm-nohead remote.pushDefault fork &&\n>> +    test_config -C pm-nohead push.default current &&\n>> +    git -C pm-nohead branch topic one-commit &&\n>> +    git -C pm-nohead branch --set-upstream-to=origin/next topic &&\n>> +\n>> +    git -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n>> +\n>> +    test_grep ! \"not yet merged to HEAD\" err &&\n>> +    test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n>> +'\n>> +\n>> +test_expect_success '--prune-merged skips branches whose upstream is \n>> gone' '\n>> +    test_when_finished \"rm -rf pm-upstream-gone\" &&\n>> +    git clone pm-upstream pm-upstream-gone &&\n>> +    git -C pm-upstream-gone remote add fork ../pm-fork &&\n>> +    test_config -C pm-upstream-gone remote.pushDefault fork &&\n>> +    test_config -C pm-upstream-gone push.default current &&\n>> +    git -C pm-upstream-gone branch one one-commit &&\n>> +    git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n>> +\n>> +    git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n>> +    git -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n>> +\n>> +    git -C pm-upstream-gone rev-parse --verify refs/heads/one\n>> +'\n>> +\n>> +test_expect_success '--prune-merged never deletes the checked-out \n>> branch' '\n>> +    test_when_finished \"rm -rf pm-head\" &&\n>> +    git clone pm-upstream pm-head &&\n>> +    git -C pm-head remote add fork ../pm-fork &&\n>> +    test_config -C pm-head remote.pushDefault fork &&\n>> +    test_config -C pm-head push.default current &&\n>> +    git -C pm-head checkout -b one one-commit &&\n>> +    git -C pm-head branch --set-upstream-to=origin/next one &&\n>> +\n>> +    git -C pm-head branch --prune-merged \"origin/*\" &&\n>> +\n>> +    git -C pm-head rev-parse --verify refs/heads/one\n>> +'\n>> +\n>> +test_expect_success '--prune-merged spares branches that push back to \n>> their upstream' '\n>> +    test_when_finished \"rm -rf pm-push-eq\" &&\n>> +    git clone pm-upstream pm-push-eq &&\n>> +    git -C pm-push-eq checkout --detach &&\n>> +\n>> +    git -C pm-push-eq branch --prune-merged \"origin/*\" &&\n>> +\n>> +    git -C pm-push-eq rev-parse --verify refs/heads/main\n>> +'\n>> +\n>> +test_expect_success '--prune-merged spares a per-branch \n>> pushRemote==upstream remote' '\n>> +    test_when_finished \"rm -rf pm-push-branch\" &&\n>> +    git clone pm-upstream pm-push-branch &&\n>> +    git -C pm-push-branch remote add fork ../pm-fork &&\n>> +    test_config -C pm-push-branch remote.pushDefault fork &&\n>> +    test_config -C pm-push-branch push.default current &&\n>> +    test_config -C pm-push-branch branch.main.pushRemote origin &&\n>> +    git -C pm-push-branch checkout --detach &&\n>> +\n>> +    git -C pm-push-branch branch --prune-merged \"origin/*\" &&\n>> +\n>> +    git -C pm-push-branch rev-parse --verify refs/heads/main\n>> +'\n>> +\n>> +test_expect_success '--prune-merged prunes when @{push} differs from \n>> @{upstream}' '\n>> +    test_when_finished \"rm -rf pm-push-diff\" &&\n>> +    git clone pm-upstream pm-push-diff &&\n>> +    git -C pm-push-diff remote add fork ../pm-fork &&\n>> +    test_config -C pm-push-diff remote.pushDefault fork &&\n>> +    test_config -C pm-push-diff push.default current &&\n>> +    git -C pm-push-diff branch topic one-commit &&\n>> +    git -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n>> +    git -C pm-push-diff checkout --detach &&\n>> +\n>> +    git -C pm-push-diff branch --prune-merged \"origin/*\" &&\n>> +\n>> +    test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/ \n>> topic\n>> +'\n>> +\n>> +test_expect_success '--prune-merged requires a value' '\n>> +    test_must_fail git -C forked branch --prune-merged 2>err &&\n>> +    test_grep \"requires a value\" err\n>> +'\n>> +\n>> +test_expect_success '--prune-merged rejects positional arguments' '\n>> +    test_must_fail git -C forked branch --prune-merged origin/one \n>> other/foreign 2>err &&\n>> +    test_grep \"does not take positional arguments\" err\n>> +'\n>> +\n>>   test_done\n> \n\n"},{"id":"544811","messageId":"CAHwyqnXqPxaSnzqn0=t8ErWW-Nh_cVHAG6KYRROz-M55t16YSw@mail.gmail.com","threadId":"65574","inReplyTo":"9f5c36a9-a3b8-403d-9c59-40367eb895bd@gmail.com","subject":"Re: [PATCH v12 1/6] branch: add --forked filter for --list mode","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-05T17:50:38Z","receivedAt":"2026-06-05T17:51:16Z","isPatch":true,"body":"Hi Phillip!\n\nGreat points all around, I will take a look at implementing them. I'll\nrespond here instead of for each specific message, and then include\ncomments as part of the next version.\n\n> > Add a --forked option to \"git branch\" list mode that keeps only\n> > branches whose configured upstream matches <branch>. The argument\n> > can be a ref (e.g. \"origin/main\", \"master\") or a shell-style\n> > glob (e.g. \"origin/*\"). The option can be repeated to widen the\n> > filter.\n>\n> Do we want to support a remote name as an alias for $remote/HEAD to\n> match \"git checkout -b $remote\"?\n\nI have been going back and forth on this, and while I like the bare\nremote, it made the implementation a lot easier after it was removed,\nas the arguments from some of the others made sense to me.\n\n\nHarald\n"},{"id":"544815","messageId":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com","subject":"[PATCH v13 0/6] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:47Z","receivedAt":"2026-06-05T18:35:56Z","isPatch":true,"body":" * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its <branch> patterns as positional arguments\n   (e.g. git branch --prune-merged origin/main 'feature*') instead of\n   repeating the option.\n\nHarald Nordgren (6):\n  branch: add --forked filter for --list mode\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --prune-merged <branch>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --dry-run for --prune-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  41 +++-\n builtin/branch.c                 | 182 ++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 367 +++++++++++++++++++++++++++++++\n 6 files changed, 650 insertions(+), 27 deletions(-)\n\n\nbase-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v13\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v13\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v12:\n\n 1:  8834c424fb ! 1:  ccd07cff25 branch: add --forked filter for --list mode\n     @@ Metadata\n       ## Commit message ##\n          branch: add --forked filter for --list mode\n      \n     -    Add a --forked option to \"git branch\" list mode that keeps only\n     +    Add a --forked option to \"git branch\" list mode that lists only\n          branches whose configured upstream matches <branch>. The argument\n     -    can be a ref (e.g. \"origin/main\", \"master\") or a shell-style\n     -    glob (e.g. \"origin/*\"). The option can be repeated to widen the\n     -    filter.\n     +    can be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n     +    (e.g. \"origin/*\"), and may be repeated to widen the filter.\n      \n     -    Because it is a filter on list mode, --forked composes with the\n     -    existing list-mode filters, so\n     +    It is an ordinary list filter, so it combines with the others:\n      \n              git branch --merged origin/main --forked 'origin/*'\n      \n     -    lists branches forked from origin that have already been\n     -    integrated into origin/main, and --no-merged inverts the question.\n     +    lists branches forked from origin that are already merged into\n     +    origin/main, and --no-merged inverts the question.\n      \n          This is the building block for --prune-merged, which deletes the\n          listed branches once they have landed on their upstream.\n     @@ Commit message\n      \n       ## Documentation/git-branch.adoc ##\n      @@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]\n     + \t   [--column[=<options>] | --no-column] [--sort=<key>]\n       \t   [--merged [<commit>]] [--no-merged [<commit>]]\n       \t   [--contains [<commit>]] [--no-contains [<commit>]]\n     - \t   [--points-at <object>] [--format=<format>]\n      +\t   [(--forked <branch>)...]\n     + \t   [--points-at <object>] [--format=<format>]\n       \t   [(-r|--remotes) | (-a|--all)]\n       \t   [--list] [<pattern>...]\n     - git branch [--track[=(direct|inherit)] | --no-track] [-f]\n     -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n     - \tPrint the name of the current branch. In detached `HEAD` state,\n     - \tnothing is printed.\n     +@@ Documentation/git-branch.adoc: merged into the named commit (i.e. the branches whose tip commits are\n     + reachable from the named commit) will be listed.  With `--no-merged` only\n     + branches not merged into the named commit will be listed.  If the _<commit>_\n     + argument is missing it defaults to `HEAD` (i.e. the tip of the current\n     +-branch).\n     ++branch).  With `--forked`, only branches whose configured upstream matches\n     ++the given branch or pattern will be listed.\n     + \n     + The command's second form creates a new branch head named _<branch-name>_\n     + which points to the current `HEAD`, or _<start-point>_ if given. As a\n     +@@ Documentation/git-branch.adoc: superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n     + \tOnly list branches whose tips are not reachable from\n     + \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n       \n      +`--forked <branch>`::\n     -+\tList only branches whose configured upstream matches\n     ++\tOnly list branches whose configured upstream matches\n      +\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n      +\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n     -+\toption can be repeated to widen the filter.\n     ++\toption can be repeated to widen the filter. Implies `--list`.\n      +\n     - `-v`::\n     - `-vv`::\n     - `--verbose`::\n     + `--points-at <object>`::\n     + \tOnly list branches of _<object>_.\n     + \n      \n       ## builtin/branch.c ##\n      @@\n     - #include \"help.h\"\n     - #include \"advice.h\"\n       #include \"commit-reach.h\"\n     -+#include \"wildmatch.h\"\n       \n       static const char * const builtin_branch_usage[] = {\n      -\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n     @@ builtin/branch.c\n       \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n       \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n       \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n     -@@ builtin/branch.c: static char *build_format(struct ref_filter *filter, int maxwidth, const char *r\n     - \treturn strbuf_detach(&fmt, NULL);\n     +@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n     + \tfree_worktrees(worktrees);\n       }\n       \n     -+static void filter_array_by_forked(struct ref_array *array,\n     -+\t\t\t\t   const struct string_list *upstreams);\n     ++static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n     ++{\n     ++\tstruct ref_filter *filter = opt->value;\n      +\n     - static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,\n     --\t\t\t   struct ref_format *format, struct string_list *output)\n     -+\t\t\t   struct ref_format *format, struct string_list *output,\n     -+\t\t\t   const struct string_list *forked_upstreams)\n     - {\n     - \tint i;\n     - \tstruct ref_array array;\n     -@@ builtin/branch.c: static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin\n     ++\tBUG_ON_OPT_NEG(unset);\n     ++\tif (ref_filter_forked_add(filter, arg) < 0)\n     ++\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n     ++\treturn 0;\n     ++}\n     ++\n     + static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n       \n     - \tfilter_refs(&array, filter, filter->kind);\n     + static int edit_branch_description(const char *branch_name)\n     +@@ builtin/branch.c: int cmd_branch(int argc,\n     + \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n     + \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n     + \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     ++\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n     ++\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n     ++\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n     + \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n     + \t\tOPT_REF_SORT(&sorting_options),\n     + \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n     +@@ builtin/branch.c: int cmd_branch(int argc,\n     + \t\tlist = 1;\n       \n     -+\tif (forked_upstreams->nr)\n     -+\t\tfilter_array_by_forked(&array, forked_upstreams);\n     -+\n     - \tif (filter->verbose)\n     - \t\tmaxwidth = calc_maxwidth(&array, strlen(remote_prefix));\n     + \tif (filter.with_commit || filter.no_commit ||\n     +-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n     ++\t    filter.reachable_from || filter.unreachable_from ||\n     ++\t    filter.points_at.nr || filter.forked.nr)\n     + \t\tlist = 1;\n       \n     -@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int\n     - \tfree_worktrees(worktrees);\n     + \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n     +\n     + ## ref-filter.c ##\n     +@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n     + \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n       }\n       \n     -+struct upstream_pattern {\n     -+\tchar *name;\n     -+\tint is_wildcard;\n     -+};\n     -+\n     -+static void upstream_pattern_list_clear(struct upstream_pattern *items,\n     -+\t\t\t\t\tsize_t nr)\n     -+{\n     -+\tsize_t i;\n     -+\tfor (i = 0; i < nr; i++)\n     -+\t\tfree(items[i].name);\n     -+\tfree(items);\n     -+}\n     -+\n      +static const char *short_upstream_name(const char *full_ref)\n      +{\n      +\tconst char *short_name = full_ref;\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\treturn short_name;\n      +}\n      +\n     -+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)\n     -+{\n     -+\tstruct object_id oid;\n     -+\tchar *full_ref = NULL;\n     -+\n     -+\tif (has_glob_specials(arg)) {\n     -+\t\tout->name = xstrdup(arg);\n     -+\t\tout->is_wildcard = 1;\n     -+\t\treturn 0;\n     -+\t}\n     -+\n     -+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n     -+\t\t\t  &full_ref, 0) == 1 &&\n     -+\t    (starts_with(full_ref, \"refs/heads/\") ||\n     -+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n     -+\t\tout->name = xstrdup(short_upstream_name(full_ref));\n     -+\t\tout->is_wildcard = 0;\n     -+\t\tfree(full_ref);\n     -+\t\treturn 0;\n     -+\t}\n     -+\tfree(full_ref);\n     -+\treturn -1;\n     -+}\n     -+\n     -+static void parse_forked_args(const struct string_list *args,\n     -+\t\t\t      struct upstream_pattern **patterns_out,\n     -+\t\t\t      size_t *nr_out)\n     -+{\n     -+\tstruct upstream_pattern *patterns;\n     -+\tsize_t i;\n     -+\n     -+\tALLOC_ARRAY(patterns, args->nr);\n     -+\tfor (i = 0; i < args->nr; i++) {\n     -+\t\tconst char *arg = args->items[i].string;\n     -+\t\tif (parse_one_forked_arg(arg, &patterns[i]) < 0) {\n     -+\t\t\tupstream_pattern_list_clear(patterns, i);\n     -+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n     -+\t\t}\n     -+\t}\n     -+\t*patterns_out = patterns;\n     -+\t*nr_out = args->nr;\n     -+}\n     -+\n     -+static int upstream_matches(const char *short_upstream,\n     -+\t\t\t    const struct upstream_pattern *patterns,\n     -+\t\t\t    size_t nr)\n     -+{\n     -+\tsize_t i;\n     -+\n     -+\tfor (i = 0; i < nr; i++) {\n     -+\t\tconst struct upstream_pattern *p = &patterns[i];\n     -+\t\tif (p->is_wildcard) {\n     -+\t\t\tif (!wildmatch(p->name, short_upstream, WM_PATHNAME))\n     -+\t\t\t\treturn 1;\n     -+\t\t} else if (!strcmp(p->name, short_upstream)) {\n     -+\t\t\treturn 1;\n     -+\t\t}\n     -+\t}\n     -+\treturn 0;\n     -+}\n     -+\n     -+static int branch_upstream_matches(const char *full_refname,\n     -+\t\t\t\t   const struct upstream_pattern *patterns,\n     -+\t\t\t\t   size_t nr_patterns)\n     ++/*\n     ++ * Match the configured upstream of a branch against the registered\n     ++ * --forked patterns. Exact patterns are compared against the full\n     ++ * upstream refname so they are unambiguous; glob patterns are matched\n     ++ * against the abbreviated upstream so that a glob such as origin/...\n     ++ * works as typed.\n     ++ */\n     ++static int filter_forked_match(struct ref_filter *filter, const char *refname)\n      +{\n      +\tconst char *short_name;\n      +\tstruct branch *branch;\n      +\tconst char *upstream;\n     ++\tint i;\n      +\n     -+\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n     ++\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n      +\t\treturn 0;\n      +\tbranch = branch_get(short_name);\n      +\tif (!branch)\n     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c\n      +\tupstream = branch_get_upstream(branch, NULL);\n      +\tif (!upstream)\n      +\t\treturn 0;\n     -+\treturn upstream_matches(short_upstream_name(upstream),\n     -+\t\t\t\tpatterns, nr_patterns);\n     ++\n     ++\tfor (i = 0; i < filter->forked.nr; i++) {\n     ++\t\tconst char *pattern = filter->forked.v[i];\n     ++\t\tif (has_glob_specials(pattern)) {\n     ++\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n     ++\t\t\t\t       WM_PATHNAME))\n     ++\t\t\t\treturn 1;\n     ++\t\t} else if (!strcmp(pattern, upstream)) {\n     ++\t\t\treturn 1;\n     ++\t\t}\n     ++\t}\n     ++\treturn 0;\n      +}\n      +\n     -+static void filter_array_by_forked(struct ref_array *array,\n     -+\t\t\t\t   const struct string_list *upstreams)\n     ++int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n      +{\n     -+\tstruct upstream_pattern *patterns = NULL;\n     -+\tsize_t nr_patterns = 0;\n     -+\tint i, kept = 0;\n     -+\n     -+\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n     ++\tstruct object_id oid;\n     ++\tchar *full_ref = NULL;\n      +\n     -+\tfor (i = 0; i < array->nr; i++) {\n     -+\t\tstruct ref_array_item *item = array->items[i];\n     -+\t\tif (branch_upstream_matches(item->refname,\n     -+\t\t\t\t\t    patterns, nr_patterns))\n     -+\t\t\tarray->items[kept++] = item;\n     -+\t\telse\n     -+\t\t\tfree_ref_array_item(item);\n     ++\tif (has_glob_specials(arg)) {\n     ++\t\tstrvec_push(&filter->forked, arg);\n     ++\t\treturn 0;\n      +\t}\n     -+\tarray->nr = kept;\n      +\n     -+\tupstream_pattern_list_clear(patterns, nr_patterns);\n     ++\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n     ++\t\t\t  &full_ref, 0) == 1 &&\n     ++\t    (starts_with(full_ref, \"refs/heads/\") ||\n     ++\t     starts_with(full_ref, \"refs/remotes/\"))) {\n     ++\t\tstrvec_push(&filter->forked, full_ref);\n     ++\t\tfree(full_ref);\n     ++\t\treturn 0;\n     ++\t}\n     ++\tfree(full_ref);\n     ++\treturn -1;\n      +}\n      +\n     - static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n     - \n     - static int edit_branch_description(const char *branch_name)\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \t/* possible actions */\n     - \tint delete = 0, rename = 0, copy = 0, list = 0,\n     - \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     -+\tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n     - \tconst char *new_upstream = NULL;\n     - \tint noncreate_actions = 0;\n     - \t/* possible options */\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n     - \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n     - \t\t\t N_(\"edit the description for the branch\")),\n     -+\t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n     -+\t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n     - \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n     - \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n     - \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tlist = 1;\n     - \n     - \tif (filter.with_commit || filter.no_commit ||\n     --\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n     -+\t    filter.reachable_from || filter.unreachable_from ||\n     -+\t    filter.points_at.nr || forked_upstreams.nr)\n     - \t\tlist = 1;\n     - \n     - \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);\n     - \t\tref_sorting_set_sort_flags_all(\n     - \t\t\tsorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);\n     --\t\tprint_ref_list(&filter, sorting, &format, &output);\n     -+\t\tprint_ref_list(&filter, sorting, &format, &output,\n     -+\t\t\t       &forked_upstreams);\n     - \t\tprint_columns(&output, colopts, NULL);\n     - \t\tstring_list_clear(&output, 0);\n     - \t\tref_sorting_release(sorting);\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \n     - out:\n     - \tstring_list_clear(&sorting_options, 0);\n     -+\tstring_list_clear(&forked_upstreams, 0);\n     - \treturn ret;\n     - }\n     -\n     - ## ref-filter.c ##\n     -@@ ref-filter.c: static int filter_one(const struct reference *ref, void *cb_data)\n     - }\n     - \n     - /*  Free memory allocated for a ref_array_item */\n     --static void free_array_item(struct ref_array_item *item)\n     -+void free_ref_array_item(struct ref_array_item *item)\n     - {\n     - \tfree((char *)item->symref);\n     - \tif (item->value) {\n     -@@ ref-filter.c: static int filter_and_format_one(const struct reference *ref, void *cb_data)\n     - \n     - \tstrbuf_release(&output);\n     - \tstrbuf_release(&err);\n     --\tfree_array_item(item);\n     -+\tfree_ref_array_item(item);\n     + /*\n     +  * We need to seek to the reference right after a given marker but excluding any\n     +  * matching references. So we seek to the lexicographically next reference.\n     +@@ ref-filter.c: static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n     + \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n     + \t\treturn NULL;\n       \n     ++\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n     ++\t\treturn NULL;\n     ++\n       \t/*\n     - \t * Increment the running count of refs that match the filter. If\n     -@@ ref-filter.c: void ref_array_clear(struct ref_array *array)\n     - \tint i;\n     - \n     - \tfor (i = 0; i < array->nr; i++)\n     --\t\tfree_array_item(array->items[i]);\n     -+\t\tfree_ref_array_item(array->items[i]);\n     - \tFREE_AND_NULL(array->items);\n     - \tarray->nr = array->alloc = 0;\n     - \n     -@@ ref-filter.c: static void reach_filter(struct ref_array *array,\n     - \t\tif (is_merged == include_reached)\n     - \t\t\tarray->items[array->nr++] = array->items[i];\n     - \t\telse\n     --\t\t\tfree_array_item(item);\n     -+\t\t\tfree_ref_array_item(item);\n     - \t}\n     - \n     - \tclear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);\n     -@@ ref-filter.c: void pretty_print_ref(const char *name, const struct object_id *oid,\n     - \n     - \tstrbuf_release(&err);\n     - \tstrbuf_release(&output);\n     --\tfree_array_item(ref_item);\n     -+\tfree_ref_array_item(ref_item);\n     - }\n     - \n     - static int parse_sorting_atom(const char *atom)\n     + \t * A merge filter is applied on refs pointing to commits. Hence\n     + \t * obtain the commit using the 'oid' available and discard all\n     +@@ ref-filter.c: void ref_filter_init(struct ref_filter *filter)\n     + void ref_filter_clear(struct ref_filter *filter)\n     + {\n     + \tstrvec_clear(&filter->exclude);\n     ++\tstrvec_clear(&filter->forked);\n     + \toid_array_clear(&filter->points_at);\n     + \tcommit_list_free(filter->with_commit);\n     + \tcommit_list_free(filter->no_commit);\n      \n       ## ref-filter.h ##\n     -@@ ref-filter.h: void filter_and_format_refs(struct ref_filter *filter, unsigned int type,\n     - \t\t\t    struct ref_format *format);\n     - /*  Clear all memory allocated to ref_array */\n     - void ref_array_clear(struct ref_array *array);\n     -+/*  Free a single item from a ref_array */\n     -+void free_ref_array_item(struct ref_array_item *item);\n     - /*  Used to verify if the given format is correct and to parse out the used atoms */\n     - int verify_ref_format(struct ref_format *format);\n     - /*  Sort the given ref_array as per the ref_sorting provided */\n     +@@ ref-filter.h: struct ref_filter {\n     + \tconst char **name_patterns;\n     + \tconst char *start_after;\n     + \tstruct strvec exclude;\n     ++\tstruct strvec forked;\n     + \tstruct oid_array points_at;\n     + \tstruct commit_list *with_commit;\n     + \tstruct commit_list *no_commit;\n     +@@ ref-filter.h: struct ref_format {\n     + #define REF_FILTER_INIT { \\\n     + \t.points_at = OID_ARRAY_INIT, \\\n     + \t.exclude = STRVEC_INIT, \\\n     ++\t.forked = STRVEC_INIT, \\\n     + }\n     + #define REF_FORMAT_INIT {             \\\n     + \t.use_color = GIT_COLOR_UNKNOWN, \\\n     +@@ ref-filter.h: void ref_sorting_release(struct ref_sorting *);\n     + struct ref_sorting *ref_sorting_options(struct string_list *);\n     + /*  Function to parse --merged and --no-merged options */\n     + int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n     ++/*\n     ++ * Register a --forked <branch> pattern on the filter. The argument is\n     ++ * either a ref, which is resolved to its full refname, or a shell-style\n     ++ * glob. Branches are kept only when their configured upstream matches\n     ++ * one of the registered patterns. Returns -1 if the argument is not a\n     ++ * valid ref or pattern.\n     ++ */\n     ++int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n     + /*  Get the current HEAD's description */\n     + char *get_head_description(void);\n     + /*  Set up translated strings in the output. */\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n 2:  6c95e4e77c ! 2:  a7672713f6 branch: let delete_branches warn instead of error on bulk refusal\n     @@ Metadata\n       ## Commit message ##\n          branch: let delete_branches warn instead of error on bulk refusal\n      \n     -    Add a warn_only flag to delete_branches() and check_branch_commit()\n     -    so a bulk caller can report not-fully-merged branches as one-line\n     -    warnings and continue, instead of erroring with the four-line \"use\n     -    'git branch -D'\" advice that the standalone \"git branch -d\" path\n     -    emits. Default callers pass 0 and are unaffected.\n     +    Add a warn-only mode to delete_branches() and check_branch_commit()\n     +    so a bulk caller can report branches that are not fully merged as a\n     +    short warning and carry on, rather than erroring with the longer\n     +    \"use 'git branch -D'\" advice that the plain \"git branch -d\" path\n     +    emits. Existing callers are unaffected.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n     + \treturn merged;\n     + }\n       \n     ++enum delete_branch_flags {\n     ++\tDELETE_BRANCH_FORCE = (1 << 0),\n     ++\tDELETE_BRANCH_QUIET = (1 << 1),\n     ++\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n     ++};\n     ++\n       static int check_branch_commit(const char *branchname, const char *refname,\n       \t\t\t       const struct object_id *oid, struct commit *head_rev,\n      -\t\t\t       int kinds, int force)\n     -+\t\t\t       int kinds, int force, int warn_only)\n     ++\t\t\t       int kinds, unsigned int flags)\n       {\n     ++\tint force = flags & DELETE_BRANCH_FORCE;\n       \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n       \tif (!force && !rev) {\n     -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n     + \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n       \t\treturn -1;\n       \t}\n       \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c\n      -\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n      -\t\t\t\t  _(\"If you are sure you want to delete it, \"\n      -\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\tif (warn_only) {\n     ++\t\tif (flags & DELETE_BRANCH_WARN_ONLY) {\n      +\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n      +\t\t\t\tbranchname);\n      +\t\t} else {\n     @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c\n       \t}\n       \treturn 0;\n      @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     + \tstrbuf_release(&buf);\n       }\n       \n     - static int delete_branches(int argc, const char **argv, int force, int kinds,\n     +-static int delete_branches(int argc, const char **argv, int force, int kinds,\n      -\t\t\t   int quiet)\n     -+\t\t\t   int quiet, int warn_only)\n     ++static int delete_branches(int argc, const char **argv, int kinds,\n     ++\t\t\t   unsigned int flags)\n       {\n       \tstruct commit *head_rev = NULL;\n       \tstruct object_id oid;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \tint i;\n     + \tint ret = 0;\n     + \tint remote_branch = 0;\n     ++\tint force = flags & DELETE_BRANCH_FORCE;\n     ++\tint quiet = flags & DELETE_BRANCH_QUIET;\n     + \tstruct strbuf bname = STRBUF_INIT;\n     + \tenum interpret_branch_kind allowed_interpret;\n     + \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \n     + \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n     + \t\tchar *target = NULL;\n     +-\t\tint flags = 0;\n     ++\t\tint ref_flags = 0;\n     + \n     + \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n     + \t\tfree(name);\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\t\t\t\t     RESOLVE_REF_READING\n     + \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n     + \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n     +-\t\t\t\t\t     &oid, &flags);\n     ++\t\t\t\t\t     &oid, &ref_flags);\n     + \t\tif (!target) {\n     + \t\t\tif (remote_branch) {\n     + \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n     + \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n     + \t\t\t\t\t\t\t\t\t   &oid,\n     +-\t\t\t\t\t\t\t\t\t   &flags);\n     ++\t\t\t\t\t\t\t\t\t   &ref_flags);\n     + \t\t\t\tFREE_AND_NULL(virtual_name);\n       \n     - \t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     + \t\t\t\tif (virtual_target)\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\t\tcontinue;\n     + \t\t}\n     + \n     +-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n     ++\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n       \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n      -\t\t\t\t\tforce)) {\n      -\t\t\tret = 1;\n     -+\t\t\t\t\tforce, warn_only)) {\n     -+\t\t\tif (!warn_only)\n     ++\t\t\t\t\tflags)) {\n     ++\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n      +\t\t\t\tret = 1;\n       \t\t\tgoto next;\n       \t\t}\n       \n     + \t\titem = string_list_append(&refs_to_delete, name);\n     +-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n     +-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n     ++\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n     ++\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n     + \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n     + \n     + \tnext:\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (delete) {\n       \t\tif (!argc)\n       \t\t\tdie(_(\"branch name required\"));\n      -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n     -+\t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     -+\t\t\t\t      quiet, 0);\n     ++\t\tret = delete_branches(argc, argv, filter.kind,\n     ++\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n     ++\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n 3:  004a96f7a4 ! 3:  5ee7643d3a branch: prepare delete_branches for a bulk caller\n     @@ Metadata\n       ## Commit message ##\n          branch: prepare delete_branches for a bulk caller\n      \n     -    Add no_head_fallback and dry_run flags to delete_branches() so a\n     -    bulk caller (the upcoming --prune-merged) can ask strictly about\n     -    merged-into-upstream without a silent fallback to HEAD, and\n     -    rehearse deletions with the same \"Would delete branch ...\" wording\n     -    as the live run. Existing callers pass 0 for both and keep current\n     -    behavior.\n     -\n     -    When no_head_fallback is set, head_rev stays NULL through to\n     -    branch_merged(), whose \"merged to X but not yet merged to HEAD\"\n     -    reminder otherwise compares against HEAD. For the bulk caller\n     -    every candidate is known to have an upstream, so HEAD is\n     -    irrelevant. Guard the block on head_rev so the NULL case skips\n     -    it instead of treating \"NULL != reference_rev\" as \"diverges from\n     -    HEAD\" and emitting a spurious warning.\n     +    Teach delete_branches() two new modes for the upcoming\n     +    --prune-merged: one that asks only whether a branch is merged into\n     +    its upstream, without falling back to HEAD when there is no\n     +    upstream, and one that rehearses the deletions without removing any\n     +    ref. Existing callers keep their current behavior.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \t\tif (expect < 0)\n       \t\t\texit(128);\n       \t\tif (expect == merged)\n     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n     - }\n     +@@ builtin/branch.c: enum delete_branch_flags {\n     + \tDELETE_BRANCH_FORCE = (1 << 0),\n     + \tDELETE_BRANCH_QUIET = (1 << 1),\n     + \tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n     ++\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n     ++\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n     + };\n       \n     - static int delete_branches(int argc, const char **argv, int force, int kinds,\n     --\t\t\t   int quiet, int warn_only)\n     -+\t\t\t   int quiet, int warn_only, int no_head_fallback,\n     -+\t\t\t   int dry_run)\n     - {\n     - \tstruct commit *head_rev = NULL;\n     - \tstruct object_id oid;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + static int check_branch_commit(const char *branchname, const char *refname,\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     + \tint remote_branch = 0;\n     + \tint force = flags & DELETE_BRANCH_FORCE;\n     + \tint quiet = flags & DELETE_BRANCH_QUIET;\n     ++\tint dry_run = flags & DELETE_BRANCH_DRY_RUN;\n     ++\tint no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n     + \tstruct strbuf bname = STRBUF_INIT;\n     + \tenum interpret_branch_kind allowed_interpret;\n     + \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n       \t}\n       \tbranch_name_pos = strcspn(fmt, \"%\");\n       \n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n       \n       \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n       \t\tfree(target);\n       \t}\n       \n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \t\t\tchar *refname = name + branch_name_pos;\n       \t\t\tif (!quiet)\n       \t\t\t\tprintf(remote_branch\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tif (!argc)\n     - \t\t\tdie(_(\"branch name required\"));\n     - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     --\t\t\t\t      quiet, 0);\n     -+\t\t\t\t      quiet, 0, 0, 0);\n     - \t\tgoto out;\n     - \t} else if (show_current) {\n     - \t\tprint_current_branch_name();\n 4:  cccfdb831c ! 4:  5f913c445c branch: add --prune-merged <branch>\n     @@ Commit message\n                  git branch --prune-merged <branch>...\n      \n          deletes the local branches that \"--forked <branch>\" would list,\n     -    restricted to those whose tip is reachable from their configured\n     +    keeping only those whose tip is reachable from their configured\n          upstream: the work has already landed on the upstream they track,\n          so the local copy is no longer needed.\n      \n     -    Reachability is read from local refs; nothing is fetched. Users\n     -    who want fresh upstream refs run \"git fetch\" first.\n     +    Reachability is read from local refs; nothing is fetched. Run\n     +    \"git fetch\" first if you want fresh upstream refs.\n      \n     -    Three classes of branches are spared:\n     +    Three kinds of branches are spared:\n      \n            * any branch checked out in any worktree;\n     -      * any branch whose upstream no longer resolves locally (its\n     -        disappearance is not, on its own, evidence of integration);\n     +      * any branch whose upstream no longer resolves locally, since a\n     +        missing upstream is not by itself a sign of integration;\n            * any branch whose push destination equals its upstream\n     -        (<branch>@{push} == <branch>@{upstream}). Such a branch\n     -        cannot be distinguished from a freshly pulled trunk that\n     -        just looks \"fully merged\", e.g. local \"main\" tracking and\n     -        pushing to \"origin/main\" right after a pull. Only branches\n     -        that push somewhere other than their upstream (typically\n     -        topics in a fork-based workflow) are treated as candidates.\n     +        (<branch>@{push} is the same as <branch>@{upstream}), such as\n     +        a local \"main\" that tracks and pushes to \"origin/main\". Right\n     +        after a pull it just looks \"fully merged\", so it is left\n     +        alone. Only branches that push somewhere other than their\n     +        upstream, typically topics in a fork workflow, are candidates.\n      \n     -    Deletion goes through the existing delete_branches() in warn-only\n     -    mode and with the HEAD-fallback disabled: a branch that is not\n     -    yet fully merged to its upstream is reported as a one-line warning\n     -    and skipped, so a single un-mergeable topic does not abort the\n     -    whole sweep. We only act on upstream-merged status.\n     +    Branches that are not yet merged into their upstream are reported\n     +    as a short warning and skipped, so one unmerged topic does not\n     +    abort the whole sweep.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     -+git branch (--prune-merged <branch>)...\n     ++git branch --prune-merged <branch>...\n       \n       DESCRIPTION\n       -----------\n      @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.\n     - \t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n     - \toption can be repeated to widen the filter.\n     + \tPrint the name of the current branch. In detached `HEAD` state,\n     + \tnothing is printed.\n       \n     -+`--prune-merged <branch>`::\n     ++`--prune-merged <branch>...`::\n      +\tDelete the local branches that `--forked` would list for the\n     -+\tsame _<branch>_, but only those whose tip is reachable from\n     -+\ttheir configured upstream. In other words, the work on the\n     -+\tbranch has already landed on the upstream it tracks, so the\n     -+\tlocal copy is no longer needed. May be given more than once to\n     -+\tunion the matches; positional arguments are not accepted.\n     ++\tgiven _<branch>_ arguments, but only those whose tip is\n     ++\treachable from their configured upstream. In other words, the\n     ++\twork on the branch has already landed on the upstream it\n     ++\ttracks, so the local copy is no longer needed. Several\n     ++\t_<branch>_ patterns may be given, e.g. `git branch\n     ++\t--prune-merged origin/main 'feature*'`.\n      ++\n      +Reachability is checked against whatever the upstream refs say\n      +locally; nothing is fetched. Run `git fetch` first if you want\n     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] (--prune-merged <branch>)...\"),\n     ++\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n       \tNULL\n       };\n       \n     -@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,\n     +@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n       \treturn 0;\n       }\n       \n     --static int branch_upstream_matches(const char *full_refname,\n     -+static int branch_upstream_matches(const char *short_branch_name,\n     - \t\t\t\t   const struct upstream_pattern *patterns,\n     - \t\t\t\t   size_t nr_patterns)\n     - {\n     --\tconst char *short_name;\n     --\tstruct branch *branch;\n     -+\tstruct branch *branch = branch_get(short_branch_name);\n     - \tconst char *upstream;\n     - \n     --\tif (!skip_prefix(full_refname, \"refs/heads/\", &short_name))\n     --\t\treturn 0;\n     --\tbranch = branch_get(short_name);\n     - \tif (!branch)\n     - \t\treturn 0;\n     - \tupstream = branch_get_upstream(branch, NULL);\n     -@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n     - \n     - \tfor (i = 0; i < array->nr; i++) {\n     - \t\tstruct ref_array_item *item = array->items[i];\n     --\t\tif (branch_upstream_matches(item->refname,\n     --\t\t\t\t\t    patterns, nr_patterns))\n     -+\t\tconst char *short_name;\n     -+\t\tif (skip_prefix(item->refname, \"refs/heads/\", &short_name) &&\n     -+\t\t    branch_upstream_matches(short_name, patterns, nr_patterns))\n     - \t\t\tarray->items[kept++] = item;\n     - \t\telse\n     - \t\t\tfree_ref_array_item(item);\n     -@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n     - \tupstream_pattern_list_clear(patterns, nr_patterns);\n     - }\n     - \n     -+struct forked_cb {\n     -+\tconst struct upstream_pattern *patterns;\n     -+\tsize_t nr_patterns;\n     -+\tstruct string_list *out;\n     -+};\n     -+\n     -+static int collect_forked_branch(const struct reference *ref, void *cb_data)\n     -+{\n     -+\tstruct forked_cb *cb = cb_data;\n     -+\n     -+\tif (ref->flags & REF_ISSYMREF)\n     -+\t\treturn 0;\n     -+\tif (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))\n     -+\t\tstring_list_append(cb->out, ref->name);\n     -+\treturn 0;\n     -+}\n     -+\n     -+static void collect_forked_set(const struct string_list *upstreams,\n     -+\t\t\t       struct string_list *out)\n     -+{\n     -+\tstruct upstream_pattern *patterns = NULL;\n     -+\tsize_t nr_patterns = 0;\n     -+\tstruct forked_cb cb;\n     -+\n     -+\tparse_forked_args(upstreams, &patterns, &nr_patterns);\n     -+\tcb.patterns = patterns;\n     -+\tcb.nr_patterns = nr_patterns;\n     -+\tcb.out = out;\n     -+\n     -+\trefs_for_each_branch_ref(get_main_ref_store(the_repository),\n     -+\t\t\t\t collect_forked_branch, &cb);\n     -+\n     -+\tstring_list_sort(out);\n     -+\n     -+\tupstream_pattern_list_clear(patterns, nr_patterns);\n     -+}\n     -+\n     -+static int prune_merged_branches(const struct string_list *upstreams,\n     ++static int prune_merged_branches(int argc, const char **argv,\n      +\t\t\t\t int quiet)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n     -+\tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     ++\tstruct ref_filter filter = REF_FILTER_INIT;\n     ++\tstruct ref_array candidates;\n      +\tstruct strvec deletable = STRVEC_INIT;\n     -+\tstruct string_list_item *item;\n     -+\tint ret = 0;\n     ++\tint i, ret = 0;\n      +\n     -+\tif (!upstreams->nr)\n     ++\tif (!argc)\n      +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n      +\n     -+\tcollect_forked_set(upstreams, &candidates);\n     ++\tfor (i = 0; i < argc; i++)\n     ++\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n     ++\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n      +\n     -+\tfor_each_string_list_item(item, &candidates) {\n     -+\t\tconst char *short_name = item->string;\n     -+\t\tstruct branch *branch = branch_get(short_name);\n     ++\tfilter.kind = FILTER_REFS_BRANCHES;\n     ++\tmemset(&candidates, 0, sizeof(candidates));\n     ++\tfilter_refs(&candidates, &filter, filter.kind);\n     ++\n     ++\tfor (i = 0; i < candidates.nr; i++) {\n     ++\t\tconst char *full_name = candidates.items[i]->refname;\n     ++\t\tconst char *short_name;\n     ++\t\tstruct branch *branch;\n      +\t\tconst char *upstream, *push;\n     -+\t\tstruct strbuf full = STRBUF_INIT;\n     -+\t\tint skip;\n      +\n     -+\t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     -+\t\tskip = !!branch_checked_out(full.buf);\n     -+\t\tstrbuf_release(&full);\n     -+\t\tif (skip)\n     ++\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     ++\t\t\tcontinue;\n     ++\t\tif (branch_checked_out(full_name))\n      +\t\t\tcontinue;\n      +\n     ++\t\tbranch = branch_get(short_name);\n      +\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n      +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n      +\t\t\tcontinue;\n     @@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n      +\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n     -+\t\t\t\t      0, /* force */\n      +\t\t\t\t      FILTER_REFS_BRANCHES,\n     -+\t\t\t\t      quiet,\n     -+\t\t\t\t      1, /* warn_only */\n     -+\t\t\t\t      1, /* no_head_fallback */\n     -+\t\t\t\t      0  /* dry_run */);\n     ++\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n     ++\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     ++\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n      +\n      +\tstrvec_clear(&deletable);\n     -+\tstring_list_clear(&candidates, 0);\n     ++\tref_array_clear(&candidates);\n     ++\tref_filter_clear(&filter);\n      +\treturn ret;\n      +}\n      +\n     @@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,\n       \n       static int edit_branch_description(const char *branch_name)\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     + \t/* possible actions */\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n     ++\tint prune_merged = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     + \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n     + \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n     - \t\tOPT_STRING_LIST(0, \"forked\", &forked_upstreams, N_(\"branch\"),\n     - \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n     -+\t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n     -+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n     ++\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     ++\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n      -\t    !show_current && !unset_upstream && argc == 0)\n     -+\t    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&\n     ++\t    !show_current && !unset_upstream && !prune_merged &&\n      +\t    argc == 0)\n       \t\tlist = 1;\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n       \t\t\t    !!show_current + !!list + !!edit_description +\n      -\t\t\t    !!unset_upstream;\n     -+\t\t\t    !!unset_upstream + !!prune_merged_upstreams.nr;\n     ++\t\t\t    !!unset_upstream + !!prune_merged;\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tret = delete_branches(argc, argv, delete > 1, filter.kind,\n     - \t\t\t\t      quiet, 0, 0, 0);\n     + \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n     + \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n     -+\t} else if (prune_merged_upstreams.nr) {\n     -+\t\tif (argc)\n     -+\t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n     -+\t\t\t      \"repeat --prune-merged for each <branch>\"));\n     -+\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n     ++\t} else if (prune_merged) {\n     ++\t\tret = prune_merged_branches(argc, argv, quiet);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n       \t\tret = 0;\n     -@@ builtin/branch.c: int cmd_branch(int argc,\n     - out:\n     - \tstring_list_clear(&sorting_options, 0);\n     - \tstring_list_clear(&forked_upstreams, 0);\n     -+\tstring_list_clear(&prune_merged_upstreams, 0);\n     - \treturn ret;\n     - }\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n      +\tgit -C pm-union checkout --detach &&\n      +\n     -+\tgit -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&\n     ++\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n      +\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n      +'\n      +\n     -+test_expect_success '--prune-merged requires a value' '\n     ++test_expect_success '--prune-merged requires at least one <branch>' '\n      +\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n     -+\ttest_grep \"requires a value\" err\n     ++\ttest_grep \"requires at least one <branch>\" err\n      +'\n      +\n     -+test_expect_success '--prune-merged rejects positional arguments' '\n     -+\ttest_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&\n     -+\ttest_grep \"does not take positional arguments\" err\n     ++test_expect_success '--prune-merged takes positional <branch> arguments' '\n     ++\ttest_when_finished \"rm -rf pm-positional\" &&\n     ++\tgit clone pm-upstream pm-positional &&\n     ++\tgit -C pm-positional remote add fork ../pm-fork &&\n     ++\ttest_config -C pm-positional remote.pushDefault fork &&\n     ++\ttest_config -C pm-positional push.default current &&\n     ++\tgit -C pm-positional branch one one-commit &&\n     ++\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n     ++\tgit -C pm-positional branch two base &&\n     ++\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n     ++\tgit -C pm-positional checkout --detach &&\n     ++\n     ++\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n     ++\n     ++\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n     ++\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n      +'\n      +\n       test_done\n 5:  5f793f8d0d ! 5:  8e9a735ffe branch: add branch.<name>.pruneMerged opt-out\n     @@ Commit message\n          branch: add branch.<name>.pruneMerged opt-out\n      \n          Setting branch.<name>.pruneMerged=false exempts that branch from\n     -    \"git branch --prune-merged\". Useful for a topic branch you want\n     -    to develop further after an initial round has been merged\n     -    upstream.\n     +    \"git branch --prune-merged\", which is useful for a topic you want\n     +    to keep developing after an early round of it has been merged\n     +    upstream. Unless --quiet is given, each skip is reported so the\n     +    user knows why their topic was kept.\n      \n     -    Unless --quiet is given, the skip is reported per branch so the\n     -    user knows why their topic was preserved.\n     -\n     -    Explicit deletion via \"git branch -d\" continues to consult the\n     -    normal merge check and is not affected by this setting.\n     +    Explicit deletion with \"git branch -d\" still uses the normal merge\n     +    check and ignores this setting.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: the upstream refs refreshed.\n       warnings and skipped; pass them to `git branch -D` explicitly if\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n     - \t\tstruct branch *branch = branch_get(short_name);\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n     + \t\tconst char *short_name;\n     + \t\tstruct branch *branch;\n       \t\tconst char *upstream, *push;\n     - \t\tstruct strbuf full = STRBUF_INIT;\n      +\t\tstruct strbuf key = STRBUF_INIT;\n     - \t\tint skip;\n      +\t\tint opt_out;\n       \n     - \t\tstrbuf_addf(&full, \"refs/heads/%s\", short_name);\n     - \t\tskip = !!branch_checked_out(full.buf);\n     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n     + \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     + \t\t\tcontinue;\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \t\tif (!push || !strcmp(push, upstream))\n       \t\t\tcontinue;\n       \n     @@ builtin/branch.c: static int prune_merged_branches(const struct string_list *ups\n       \n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '\n     - \ttest_grep \"does not take positional arguments\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '\n     + \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n       '\n       \n      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n 6:  1a0d5eab15 ! 6:  511de4788e branch: add --dry-run for --prune-merged\n     @@ Commit message\n          branch: add --dry-run for --prune-merged\n      \n          With --dry-run, --prune-merged prints the local branches it would\n     -    delete, one \"Would delete branch <name>\" line per candidate, and\n     -    exits without touching any ref.\n     +    delete, one \"Would delete branch <name>\" line each, and exits\n     +    without touching any ref. The same filtering applies, so the output\n     +    is exactly the set that the real run would delete.\n      \n     -    The @{push}-vs-@{upstream} and unmerged filtering still applies,\n     -    so the dry-run output is exactly the set that the live run would\n     -    delete.\n     -\n     -    --dry-run is only meaningful in combination with --prune-merged\n     -    and is rejected otherwise.\n     +    --dry-run is only meaningful together with --prune-merged and is\n     +    rejected otherwise.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     --git branch (--prune-merged <branch>)...\n     -+git branch [--dry-run] (--prune-merged <branch>)...\n     +-git branch --prune-merged <branch>...\n     ++git branch [--dry-run] --prune-merged <branch>...\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety che\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,\n     +@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n       }\n       \n     - static int prune_merged_branches(const struct string_list *upstreams,\n     + static int prune_merged_branches(int argc, const char **argv,\n      -\t\t\t\t int quiet)\n      +\t\t\t\t int quiet, int dry_run)\n       {\n       \tstruct ref_store *refs = get_main_ref_store(the_repository);\n     - \tstruct string_list candidates = STRING_LIST_INIT_DUP;\n     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,\n     - \t\t\t\t      quiet,\n     - \t\t\t\t      1, /* warn_only */\n     - \t\t\t\t      1, /* no_head_fallback */\n     --\t\t\t\t      0  /* dry_run */);\n     -+\t\t\t\t      dry_run);\n     + \tstruct ref_filter filter = REF_FILTER_INIT;\n     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n     + \t\t\t\t      FILTER_REFS_BRANCHES,\n     + \t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n     + \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     +-\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n     ++\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n     ++\t\t\t\t      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n       \n       \tstrvec_clear(&deletable);\n     - \tstring_list_clear(&candidates, 0);\n     + \tref_array_clear(&candidates);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     + \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tstruct string_list forked_upstreams = STRING_LIST_INIT_DUP;\n     - \tstruct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;\n     + \tint prune_merged = 0;\n      +\tint dry_run = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\tN_(\"list local branches whose upstream matches <branch> (repeatable)\")),\n     - \t\tOPT_STRING_LIST(0, \"prune-merged\", &prune_merged_upstreams, N_(\"branch\"),\n     - \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged (repeatable)\")),\n     + \t\t\t N_(\"edit the description for the branch\")),\n     + \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     + \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n      +\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     -+\tif (dry_run && !prune_merged_upstreams.nr)\n     ++\tif (dry_run && !prune_merged)\n      +\t\tdie(_(\"--dry-run requires --prune-merged\"));\n      +\n       \tif (recurse_submodules_explicit) {\n       \t\tif (!submodule_propagate_branches)\n       \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tif (argc)\n     - \t\t\tdie(_(\"--prune-merged does not take positional arguments; \"\n     - \t\t\t      \"repeat --prune-merged for each <branch>\"));\n     --\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet);\n     -+\t\tret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);\n     + \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n     + \t\tgoto out;\n     + \t} else if (prune_merged) {\n     +-\t\tret = prune_merged_branches(argc, argv, quiet);\n     ++\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n\n-- \ngitgitgadget\n"},{"id":"544813","messageId":"ccd07cff250d7b422f1b6a915ff0200e50e55e30.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 1/6] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:48Z","receivedAt":"2026-06-05T18:35:57Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc | 10 +++-\n builtin/branch.c              | 18 ++++++-\n ref-filter.c                  | 70 ++++++++++++++++++++++++++\n ref-filter.h                  | 10 ++++\n t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++\n 5 files changed, 197 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..62ebab6051 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,12 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n+\toption can be repeated to widen the filter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..1ddd5a3f6d 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..4e7deddc04 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch local-base &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached &&\n+\tgit -C forked branch --track local-trunk local-base\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-trunk >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-trunk\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout detached\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544814","messageId":"a7672713f67d6a44992c0f0cf989770c7e9ca38b.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:49Z","receivedAt":"2026-06-05T18:35:58Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a warn-only mode to delete_branches() and check_branch_commit()\nso a bulk caller can report branches that are not fully merged as a\nshort warning and carry on, rather than erroring with the longer\n\"use 'git branch -D'\" advice that the plain \"git branch -d\" path\nemits. Existing callers are unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 50 ++++++++++++++++++++++++++++++++----------------\n 1 file changed, 34 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..19d6147e71 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,20 +189,33 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tint force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (flags & DELETE_BRANCH_WARN_ONLY) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +240,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tint force = flags & DELETE_BRANCH_FORCE;\n+\tint quiet = flags & DELETE_BRANCH_QUIET;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -257,7 +272,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +294,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +306,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +321,17 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tflags)) {\n+\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +888,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"544816","messageId":"5ee7643d3a585154f1b2c92fd1bd8edc7dfa5774.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:50Z","receivedAt":"2026-06-05T18:35:59Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--prune-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 26 ++++++++++++++++++++------\n 1 file changed, 20 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 19d6147e71..9568bb8445 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,8 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -242,6 +247,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint remote_branch = 0;\n \tint force = flags & DELETE_BRANCH_FORCE;\n \tint quiet = flags & DELETE_BRANCH_QUIET;\n+\tint dry_run = flags & DELETE_BRANCH_DRY_RUN;\n+\tint no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -267,7 +274,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -338,13 +345,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n-- \ngitgitgadget\n\n"},{"id":"544817","messageId":"5f913c445cbd68a78c71b6430e29350d559a970e.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:51Z","receivedAt":"2026-06-05T18:36:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --prune-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream: the work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nReachability is read from local refs; nothing is fetched. Run\n\"git fetch\" first if you want fresh upstream refs.\n\nThree kinds of branches are spared:\n\n  * any branch checked out in any worktree;\n  * any branch whose upstream no longer resolves locally, since a\n    missing upstream is not by itself a sign of integration;\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} is the same as <branch>@{upstream}), such as\n    a local \"main\" that tracks and pushes to \"origin/main\". Right\n    after a pull it just looks \"fully merged\", so it is left\n    alone. Only branches that push somewhere other than their\n    upstream, typically topics in a fork workflow, are candidates.\n\nBranches that are not yet merged into their upstream are reported\nas a short warning and skipped, so one unmerged topic does not\nabort the whole sweep.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  24 ++++\n builtin/branch.c              |  67 +++++++++++-\n t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++\n 3 files changed, 290 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 62ebab6051..fdaccc9662 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--prune-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--prune-merged origin/main 'feature*'`.\n++\n+Reachability is checked against whatever the upstream refs say\n+locally; nothing is fetched. Run `git fetch` first if you want\n+the upstream refs refreshed.\n++\n+A branch is left alone if any of the following holds:\n+its upstream no longer resolves locally; it is checked out in any\n+worktree; or its push destination (`<branch>@{push}`) equals its\n+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n+from a freshly pulled trunk that just looks \"fully merged\".\n++\n+Branches refused by the \"fully merged\" safety check are listed as\n+warnings and skipped; pass them to `git branch -D` explicitly if\n+you want them gone.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 9568bb8445..7a26447b2a 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n \tNULL\n };\n \n@@ -713,6 +714,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tmemset(&candidates, 0, sizeof(candidates));\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tcontinue;\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\n+\tstrvec_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -754,6 +810,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -807,6 +864,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -854,7 +913,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -864,7 +924,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -906,6 +966,9 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4e7deddc04..27ea1319bb 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main &&\n+\ttest_create_repo pm-fork\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged remote add fork ../pm-fork &&\n+\ttest_config -C pm-merged remote.pushDefault fork &&\n+\ttest_config -C pm-merged push.default current &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a literal upstream' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal remote add fork ../pm-fork &&\n+\ttest_config -C pm-literal remote.pushDefault fork &&\n+\ttest_config -C pm-literal push.default current &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-literal branch --prune-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union remote add fork ../pm-fork &&\n+\ttest_config -C pm-union remote.pushDefault fork &&\n+\ttest_config -C pm-union push.default current &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-union checkout --detach &&\n+\n+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a local upstream' '\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\tgit clone pm-upstream pm-local &&\n+\tgit -C pm-local remote add fork ../pm-fork &&\n+\ttest_config -C pm-local remote.pushDefault fork &&\n+\ttest_config -C pm-local push.default current &&\n+\tgit -C pm-local checkout -b trunk &&\n+\tgit -C pm-local branch one one-commit &&\n+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n+\tgit -C pm-local merge --ff-only one-commit &&\n+\n+\tgit -C pm-local branch --prune-merged trunk &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n+\ttest_config -C pm-unmerged push.default current &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n+\ttest_when_finished \"rm -rf pm-nohead\" &&\n+\tgit clone pm-upstream pm-nohead &&\n+\tgit -C pm-nohead remote add fork ../pm-fork &&\n+\ttest_config -C pm-nohead remote.pushDefault fork &&\n+\ttest_config -C pm-nohead push.default current &&\n+\tgit -C pm-nohead branch topic one-commit &&\n+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n+\n+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n+\n+\ttest_grep ! \"not yet merged to HEAD\" err &&\n+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n+\ttest_config -C pm-upstream-gone push.default current &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head remote add fork ../pm-fork &&\n+\ttest_config -C pm-head remote.pushDefault fork &&\n+\ttest_config -C pm-head push.default current &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n+\tgit clone pm-upstream pm-push-eq &&\n+\tgit -C pm-push-eq checkout --detach &&\n+\n+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n+\tgit clone pm-upstream pm-push-branch &&\n+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n+\ttest_config -C pm-push-branch push.default current &&\n+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n+\tgit -C pm-push-branch checkout --detach &&\n+\n+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n+\tgit clone pm-upstream pm-push-diff &&\n+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n+\ttest_config -C pm-push-diff push.default current &&\n+\tgit -C pm-push-diff branch topic one-commit &&\n+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n+\tgit -C pm-push-diff checkout --detach &&\n+\n+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n+test_expect_success '--prune-merged takes positional <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-positional\" &&\n+\tgit clone pm-upstream pm-positional &&\n+\tgit -C pm-positional remote add fork ../pm-fork &&\n+\ttest_config -C pm-positional remote.pushDefault fork &&\n+\ttest_config -C pm-positional push.default current &&\n+\tgit -C pm-positional branch one one-commit &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-positional branch two base &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-positional checkout --detach &&\n+\n+\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544818","messageId":"8e9a735ffe00f3f3efa75110c4ba2594723a1417.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:52Z","receivedAt":"2026-06-05T18:36:03Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n\"git branch --prune-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 ++++++++++++++\n t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++\n 4 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..6c1b5bb9cd 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex fdaccc9662..5c43dc55a8 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -217,9 +217,10 @@ the upstream refs refreshed.\n +\n A branch is left alone if any of the following holds:\n its upstream no longer resolves locally; it is checked out in any\n-worktree; or its push destination (`<branch>@{push}`) equals its\n+worktree; its push destination (`<branch>@{push}`) equals its\n upstream (`<branch>@{upstream}`), so it cannot be distinguished\n-from a freshly pulled trunk that just looks \"fully merged\".\n+from a freshly pulled trunk that just looks \"fully merged\"; or\n+`branch.<name>.pruneMerged` is set to `false`.\n +\n Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 7a26447b2a..be4218ded3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -739,6 +739,8 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tstruct strbuf key = STRBUF_INIT;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tcontinue;\n@@ -753,6 +755,18 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 27ea1319bb..3f7b1fc3d6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '\n \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout remote add fork ../pm-fork &&\n+\ttest_config -C pm-optout remote.pushDefault fork &&\n+\ttest_config -C pm-optout push.default current &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"544819","messageId":"511de4788ed4589b5e63db15cb1b75d5eb4b5c59.1780684553.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v13 6/6] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-05T18:35:53Z","receivedAt":"2026-06-05T18:36:04Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --prune-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --prune-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 13 ++++++++---\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 61 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5c43dc55a8..1f49a831fd 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --prune-merged <branch>...\n+git branch [--dry-run] --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,12 @@ Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\n you want them gone.\n \n+`--dry-run`::\n+\tWith `--prune-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex be4218ded3..98e56d4ff8 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -715,7 +715,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n }\n \n static int prune_merged_branches(int argc, const char **argv,\n-\t\t\t\t int quiet)\n+\t\t\t\t int quiet, int dry_run)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct ref_filter filter = REF_FILTER_INIT;\n@@ -775,7 +775,8 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\t\t\t      FILTER_REFS_BRANCHES,\n \t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n-\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \n \tstrvec_clear(&deletable);\n \tref_array_clear(&candidates);\n@@ -825,6 +826,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint prune_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +882,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -942,6 +946,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !prune_merged)\n+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -981,7 +988,7 @@ int cmd_branch(int argc,\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3f7b1fc3d6..305c0141fc 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dry\" &&\n+\tgit clone pm-upstream pm-dry &&\n+\tgit -C pm-dry remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry remote.pushDefault fork &&\n+\ttest_config -C pm-dry push.default current &&\n+\tgit -C pm-dry branch one one-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-dry branch two two-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n+\ttest_grep \"Would delete branch one \" actual &&\n+\ttest_grep \"Would delete branch two \" actual &&\n+\n+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n+\tgit -C pm-dry rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n+\tgit clone pm-upstream pm-dry-mixed &&\n+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n+\ttest_config -C pm-dry-mixed push.default current &&\n+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dry-mixed local-only &&\n+\tgit -C pm-dry-mixed checkout - &&\n+\tgit -C pm-dry-mixed branch merged one-commit &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch merged\" out &&\n+\ttest_grep ! \"Would delete branch wip\" out &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run without --prune-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --prune-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"544986","messageId":"xmqq4ijcvb64.fsf@gitster.g","threadId":"65574","inReplyTo":"a7672713f67d6a44992c0f0cf989770c7e9ca38b.1780684553.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-08T23:56:35Z","receivedAt":"2026-06-08T23:56:38Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n>\n> Add a warn-only mode to delete_branches() and check_branch_commit()\n> so a bulk caller can report branches that are not fully merged as a\n> short warning and carry on, rather than erroring with the longer\n> \"use 'git branch -D'\" advice that the plain \"git branch -d\" path\n> emits. Existing callers are unaffected.\n>\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>  builtin/branch.c | 50 ++++++++++++++++++++++++++++++++----------------\n>  1 file changed, 34 insertions(+), 16 deletions(-)\n\nThis breaks t5404, t5514, and t5505, which contradicts with\n\"Existing callers are unaffected\".\n\nWhat's going on?  It is troubling that the breakage happens without\neven getting merged with other topics in-flight, which means that\nthe environment you are developing in and testing on and the\nenvironment that I apply patches on, integrate and test (something\nbased on Debian testing) are somehow behaving differently.\n\n\"cd t && sh t5404-*.sh -i -v\" ends like so:\n\nexpecting success of 5404.7 'already deleted tracking branches ignored':\n        git branch -d -r origin/b3 &&\n        git push origin :b3 >output 2>&1 &&\n        ! grep \"^error: \" output\n\nerror: the branch 'origin/b3' is not fully merged\nhint: If you are sure you want to delete it, run 'git branch -D origin/b3'\nhint: Disable this message with \"git config set advice.forceDeleteBranch false\"\nnot ok 7 - already deleted tracking branches ignored\n#\n#               git branch -d -r origin/b3 &&\n#               git push origin :b3 >output 2>&1 &&\n#               ! grep \"^error: \" output\n#\n1..7\n\nbut it may be possible that earlier steps are behaving differently\nwith the patches applied.  I didn't dig further but I think the CI\nin the recent past have been affected by the same breakage.\n\n"},{"id":"545020","messageId":"CAHwyqnWpkF-8czt8+G4GJpMTb1qXG6FtN1HKrT5H+OcfAjQL=Q@mail.gmail.com","threadId":"65574","inReplyTo":"xmqq4ijcvb64.fsf@gitster.g","subject":"Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-09T07:52:30Z","receivedAt":"2026-06-09T07:53:08Z","isPatch":true,"body":"> This breaks t5404, t5514, and t5505, which contradicts with\n> \"Existing callers are unaffected\".\n>\n> What's going on?  It is troubling that the breakage happens without\n> even getting merged with other topics in-flight, which means that\n> the environment you are developing in and testing on and the\n> environment that I apply patches on, integrate and test (something\n> based on Debian testing) are somehow behaving differently.\n>\n> \"cd t && sh t5404-*.sh -i -v\" ends like so:\n>\n> expecting success of 5404.7 'already deleted tracking branches ignored':\n>         git branch -d -r origin/b3 &&\n>         git push origin :b3 >output 2>&1 &&\n>         ! grep \"^error: \" output\n>\n> error: the branch 'origin/b3' is not fully merged\n> hint: If you are sure you want to delete it, run 'git branch -D origin/b3'\n> hint: Disable this message with \"git config set advice.forceDeleteBranch false\"\n> not ok 7 - already deleted tracking branches ignored\n> #\n> #               git branch -d -r origin/b3 &&\n> #               git push origin :b3 >output 2>&1 &&\n> #               ! grep \"^error: \" output\n> #\n> 1..7\n>\n> but it may be possible that earlier steps are behaving differently\n> with the patches applied.  I didn't dig further but I think the CI\n> in the recent past have been affected by the same breakage.\n\nThanks for directing my attention to this.\n\nThe GitHub CI has been broken for some time, maybe I should have told\nyou about this earlier, but it coincided with a period where other\nopen source projects I worked on also had mass CI failures, so I\nchalked it up to upstream issues (GitHub, Linux, etc). But it seems to\nhave not gone away.\n\nAll of my GitHub pull requests have broken tests (see e.g. which a\nquite minimal change: https://github.com/git/git/pull/2313). This\nmakes it harder to detect actual issues. But of course it's not an\nexcuse.\n\n\nHarald\n"},{"id":"545046","messageId":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com","subject":"[PATCH v14 0/6] branch: prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:51Z","receivedAt":"2026-06-09T10:11:59Z","isPatch":true,"body":" * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nHarald Nordgren (6):\n  branch: add --forked filter for --list mode\n  branch: let delete_branches warn instead of error on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --prune-merged <branch>\n  branch: add branch.<name>.pruneMerged opt-out\n  branch: add --dry-run for --prune-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  41 +++-\n builtin/branch.c                 | 186 +++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 367 +++++++++++++++++++++++++++++++\n 6 files changed, 653 insertions(+), 28 deletions(-)\n\n\nbase-commit: 600fe743028cbfb640855f659e9851522214bc0b\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v14\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v14\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v13:\n\n 1:  ccd07cff25 = 1:  7383872f4b branch: add --forked filter for --list mode\n 2:  a7672713f6 ! 2:  7ef9502e01 branch: let delete_branches warn instead of error on bulk refusal\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \tint i;\n       \tint ret = 0;\n       \tint remote_branch = 0;\n     -+\tint force = flags & DELETE_BRANCH_FORCE;\n     -+\tint quiet = flags & DELETE_BRANCH_QUIET;\n     ++\tint force, quiet;\n       \tstruct strbuf bname = STRBUF_INIT;\n       \tenum interpret_branch_kind allowed_interpret;\n       \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\tremote_branch = 1;\n     + \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n     + \n     +-\t\tforce = 1;\n     ++\t\tflags |= DELETE_BRANCH_FORCE;\n     + \t\tbreak;\n     + \tcase FILTER_REFS_BRANCHES:\n     + \t\tfmt = \"refs/heads/%s\";\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t}\n     + \tbranch_name_pos = strcspn(fmt, \"%\");\n     + \n     ++\tforce = flags & DELETE_BRANCH_FORCE;\n     ++\tquiet = flags & DELETE_BRANCH_QUIET;\n     ++\n     + \tif (!force)\n     + \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n       \n       \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n       \t\tchar *target = NULL;\n 3:  5ee7643d3a ! 3:  259113e304 branch: prepare delete_branches for a bulk caller\n     @@ builtin/branch.c: enum delete_branch_flags {\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     + \tint i;\n     + \tint ret = 0;\n       \tint remote_branch = 0;\n     - \tint force = flags & DELETE_BRANCH_FORCE;\n     - \tint quiet = flags & DELETE_BRANCH_QUIET;\n     -+\tint dry_run = flags & DELETE_BRANCH_DRY_RUN;\n     -+\tint no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n     +-\tint force, quiet;\n     ++\tint force, quiet, dry_run, no_head_fallback;\n       \tstruct strbuf bname = STRBUF_INIT;\n       \tenum interpret_branch_kind allowed_interpret;\n       \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \t}\n     - \tbranch_name_pos = strcspn(fmt, \"%\");\n     + \n     + \tforce = flags & DELETE_BRANCH_FORCE;\n     + \tquiet = flags & DELETE_BRANCH_QUIET;\n     ++\tdry_run = flags & DELETE_BRANCH_DRY_RUN;\n     ++\tno_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n       \n      -\tif (!force)\n      +\tif (!force && !no_head_fallback)\n 4:  5f913c445c = 4:  9924373da0 branch: add --prune-merged <branch>\n 5:  8e9a735ffe = 5:  d691d5051b branch: add branch.<name>.pruneMerged opt-out\n 6:  511de4788e = 6:  ede8c61729 branch: add --dry-run for --prune-merged\n\n-- \ngitgitgadget\n"},{"id":"545047","messageId":"7383872f4b2f422ec36b11ab5fb31cce08e6106a.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 1/6] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:52Z","receivedAt":"2026-06-09T10:12:00Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --prune-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc | 10 +++-\n builtin/branch.c              | 18 ++++++-\n ref-filter.c                  | 70 ++++++++++++++++++++++++++\n ref-filter.h                  | 10 ++++\n t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++\n 5 files changed, 197 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..62ebab6051 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,12 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n+\toption can be repeated to widen the filter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..1ddd5a3f6d 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..4e7deddc04 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add other ../forked-other &&\n+\tgit -C forked fetch other &&\n+\tgit -C forked branch local-base &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch detached &&\n+\tgit -C forked branch --track local-trunk local-base\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-trunk >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-trunk\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout detached\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545048","messageId":"7ef9502e01055fd5442550cf34d491fd21a9b971.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:53Z","receivedAt":"2026-06-09T10:12:01Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a warn-only mode to delete_branches() and check_branch_commit()\nso a bulk caller can report branches that are not fully merged as a\nshort warning and carry on, rather than erroring with the longer\n\"use 'git branch -D'\" advice that the plain \"git branch -d\" path\nemits. Existing callers are unaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 54 +++++++++++++++++++++++++++++++++---------------\n 1 file changed, 37 insertions(+), 17 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..4fb012c7a4 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,20 +189,33 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tint force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (flags & DELETE_BRANCH_WARN_ONLY) {\n+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t\tbranchname);\n+\t\t} else {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tint force, quiet;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -241,7 +255,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n+\tforce = flags & DELETE_BRANCH_FORCE;\n+\tquiet = flags & DELETE_BRANCH_QUIET;\n+\n \tif (!force)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +296,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +308,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +323,17 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n-\t\t\tret = 1;\n+\t\t\t\t\tflags)) {\n+\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +890,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"545049","messageId":"259113e304c4085c2bd90cce3a40c965744d5a00.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:54Z","receivedAt":"2026-06-09T10:12:02Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--prune-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 28 +++++++++++++++++++++-------\n 1 file changed, 21 insertions(+), 7 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 4fb012c7a4..2cc5a8cde0 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,8 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n-\tint force, quiet;\n+\tint force, quiet, dry_run, no_head_fallback;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -268,8 +273,10 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tforce = flags & DELETE_BRANCH_FORCE;\n \tquiet = flags & DELETE_BRANCH_QUIET;\n+\tdry_run = flags & DELETE_BRANCH_DRY_RUN;\n+\tno_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -340,13 +347,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n-- \ngitgitgadget\n\n"},{"id":"545050","messageId":"9924373da0a0598cabe4f08f3bc4200833679171.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:55Z","receivedAt":"2026-06-09T10:12:03Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --prune-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream: the work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nReachability is read from local refs; nothing is fetched. Run\n\"git fetch\" first if you want fresh upstream refs.\n\nThree kinds of branches are spared:\n\n  * any branch checked out in any worktree;\n  * any branch whose upstream no longer resolves locally, since a\n    missing upstream is not by itself a sign of integration;\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} is the same as <branch>@{upstream}), such as\n    a local \"main\" that tracks and pushes to \"origin/main\". Right\n    after a pull it just looks \"fully merged\", so it is left\n    alone. Only branches that push somewhere other than their\n    upstream, typically topics in a fork workflow, are candidates.\n\nBranches that are not yet merged into their upstream are reported\nas a short warning and skipped, so one unmerged topic does not\nabort the whole sweep.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  24 ++++\n builtin/branch.c              |  67 +++++++++++-\n t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++\n 3 files changed, 290 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 62ebab6051..fdaccc9662 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--prune-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--prune-merged origin/main 'feature*'`.\n++\n+Reachability is checked against whatever the upstream refs say\n+locally; nothing is fetched. Run `git fetch` first if you want\n+the upstream refs refreshed.\n++\n+A branch is left alone if any of the following holds:\n+its upstream no longer resolves locally; it is checked out in any\n+worktree; or its push destination (`<branch>@{push}`) equals its\n+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n+from a freshly pulled trunk that just looks \"fully merged\".\n++\n+Branches refused by the \"fully merged\" safety check are listed as\n+warnings and skipped; pass them to `git branch -D` explicitly if\n+you want them gone.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2cc5a8cde0..af37a0ceb7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n \tNULL\n };\n \n@@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+static int prune_merged_branches(int argc, const char **argv,\n+\t\t\t\t int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates;\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tmemset(&candidates, 0, sizeof(candidates));\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tcontinue;\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\n+\tstrvec_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -756,6 +812,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint prune_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -809,6 +866,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -856,7 +915,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !prune_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -866,7 +926,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!prune_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -908,6 +968,9 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (prune_merged) {\n+\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4e7deddc04..27ea1319bb 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--prune-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main &&\n+\ttest_create_repo pm-fork\n+'\n+\n+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged remote add fork ../pm-fork &&\n+\ttest_config -C pm-merged remote.pushDefault fork &&\n+\ttest_config -C pm-merged push.default current &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a literal upstream' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal remote add fork ../pm-fork &&\n+\ttest_config -C pm-literal remote.pushDefault fork &&\n+\ttest_config -C pm-literal push.default current &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-literal branch --prune-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union remote add fork ../pm-fork &&\n+\ttest_config -C pm-union remote.pushDefault fork &&\n+\ttest_config -C pm-union push.default current &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-union checkout --detach &&\n+\n+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged accepts a local upstream' '\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\tgit clone pm-upstream pm-local &&\n+\tgit -C pm-local remote add fork ../pm-fork &&\n+\ttest_config -C pm-local remote.pushDefault fork &&\n+\ttest_config -C pm-local push.default current &&\n+\tgit -C pm-local checkout -b trunk &&\n+\tgit -C pm-local branch one one-commit &&\n+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n+\tgit -C pm-local merge --ff-only one-commit &&\n+\n+\tgit -C pm-local branch --prune-merged trunk &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n+\ttest_config -C pm-unmerged push.default current &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n+\ttest_grep \"not fully merged\" err &&\n+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n+\ttest_when_finished \"rm -rf pm-nohead\" &&\n+\tgit clone pm-upstream pm-nohead &&\n+\tgit -C pm-nohead remote add fork ../pm-fork &&\n+\ttest_config -C pm-nohead remote.pushDefault fork &&\n+\ttest_config -C pm-nohead push.default current &&\n+\tgit -C pm-nohead branch topic one-commit &&\n+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n+\n+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n+\n+\ttest_grep ! \"not yet merged to HEAD\" err &&\n+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n+\ttest_config -C pm-upstream-gone push.default current &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head remote add fork ../pm-fork &&\n+\ttest_config -C pm-head remote.pushDefault fork &&\n+\ttest_config -C pm-head push.default current &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n+\tgit clone pm-upstream pm-push-eq &&\n+\tgit -C pm-push-eq checkout --detach &&\n+\n+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n+\tgit clone pm-upstream pm-push-branch &&\n+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n+\ttest_config -C pm-push-branch push.default current &&\n+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n+\tgit -C pm-push-branch checkout --detach &&\n+\n+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n+\tgit clone pm-upstream pm-push-diff &&\n+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n+\ttest_config -C pm-push-diff push.default current &&\n+\tgit -C pm-push-diff branch topic one-commit &&\n+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n+\tgit -C pm-push-diff checkout --detach &&\n+\n+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--prune-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n+test_expect_success '--prune-merged takes positional <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-positional\" &&\n+\tgit clone pm-upstream pm-positional &&\n+\tgit -C pm-positional remote add fork ../pm-fork &&\n+\ttest_config -C pm-positional remote.pushDefault fork &&\n+\ttest_config -C pm-positional push.default current &&\n+\tgit -C pm-positional branch one one-commit &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-positional branch two base &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-positional checkout --detach &&\n+\n+\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545051","messageId":"d691d5051b35a569dbd3f4a0488030a7d84d72f9.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:56Z","receivedAt":"2026-06-09T10:12:04Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.pruneMerged=false exempts that branch from\n\"git branch --prune-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 ++++++++++++++\n t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++\n 4 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..6c1b5bb9cd 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.pruneMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --prune-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex fdaccc9662..5c43dc55a8 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -217,9 +217,10 @@ the upstream refs refreshed.\n +\n A branch is left alone if any of the following holds:\n its upstream no longer resolves locally; it is checked out in any\n-worktree; or its push destination (`<branch>@{push}`) equals its\n+worktree; its push destination (`<branch>@{push}`) equals its\n upstream (`<branch>@{upstream}`), so it cannot be distinguished\n-from a freshly pulled trunk that just looks \"fully merged\".\n+from a freshly pulled trunk that just looks \"fully merged\"; or\n+`branch.<name>.pruneMerged` is set to `false`.\n +\n Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex af37a0ceb7..52a0371292 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -741,6 +741,8 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tstruct strbuf key = STRBUF_INIT;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tcontinue;\n@@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 27ea1319bb..3f7b1fc3d6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2010,4 +2010,34 @@ test_expect_success '--prune-merged takes positional <branch> arguments' '\n \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n '\n \n+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout remote add fork ../pm-fork &&\n+\ttest_config -C pm-optout remote.pushDefault fork &&\n+\ttest_config -C pm-optout push.default current &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545052","messageId":"ede8c6172963fb8d15f0ae28f4e11501cf42be6c.1780999917.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v14 6/6] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-09T10:11:57Z","receivedAt":"2026-06-09T10:12:05Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --prune-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --prune-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 13 ++++++++---\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 61 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 5c43dc55a8..1f49a831fd 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --prune-merged <branch>...\n+git branch [--dry-run] --prune-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,12 @@ Branches refused by the \"fully merged\" safety check are listed as\n warnings and skipped; pass them to `git branch -D` explicitly if\n you want them gone.\n \n+`--dry-run`::\n+\tWith `--prune-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 52a0371292..7c52a88af2 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n }\n \n static int prune_merged_branches(int argc, const char **argv,\n-\t\t\t\t int quiet)\n+\t\t\t\t int quiet, int dry_run)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct ref_filter filter = REF_FILTER_INIT;\n@@ -777,7 +777,8 @@ static int prune_merged_branches(int argc, const char **argv,\n \t\t\t\t      FILTER_REFS_BRANCHES,\n \t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n-\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \n \tstrvec_clear(&deletable);\n \tref_array_clear(&candidates);\n@@ -827,6 +828,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint prune_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -882,6 +884,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -944,6 +948,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !prune_merged)\n+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -983,7 +990,7 @@ int cmd_branch(int argc,\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (prune_merged) {\n-\t\tret = prune_merged_branches(argc, argv, quiet);\n+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3f7b1fc3d6..305c0141fc 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dry\" &&\n+\tgit clone pm-upstream pm-dry &&\n+\tgit -C pm-dry remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry remote.pushDefault fork &&\n+\ttest_config -C pm-dry push.default current &&\n+\tgit -C pm-dry branch one one-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-dry branch two two-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n+\ttest_grep \"Would delete branch one \" actual &&\n+\ttest_grep \"Would delete branch two \" actual &&\n+\n+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n+\tgit -C pm-dry rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n+\tgit clone pm-upstream pm-dry-mixed &&\n+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n+\ttest_config -C pm-dry-mixed push.default current &&\n+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dry-mixed local-only &&\n+\tgit -C pm-dry-mixed checkout - &&\n+\tgit -C pm-dry-mixed branch merged one-commit &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch merged\" out &&\n+\ttest_grep ! \"Would delete branch wip\" out &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run without --prune-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --prune-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"545065","messageId":"xmqqa4t3ubwj.fsf@gitster.g","threadId":"65574","inReplyTo":"CAHwyqnWpkF-8czt8+G4GJpMTb1qXG6FtN1HKrT5H+OcfAjQL=Q@mail.gmail.com","subject":"Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-09T12:38:20Z","receivedAt":"2026-06-09T12:38:23Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n> The GitHub CI has been broken for some time, maybe I should have told\n> you about this earlier, but it coincided with a period where other\n> open source projects I worked on also had mass CI failures, so I\n> chalked it up to upstream issues (GitHub, Linux, etc). But it seems to\n> have not gone away.\n>\n> All of my GitHub pull requests have broken tests (see e.g. which a\n> quite minimal change: https://github.com/git/git/pull/2313). This\n> makes it harder to detect actual issues. But of course it's not an\n> excuse.\n\nFWIW, the breakage was observed in my local testing, and that is why\nI found it so dusturbing.  Apparently you didn't see such breakages\nthat can be detected so easily during your local testing (otherwise\nyou wouldn't have pushed it out to update your GitHub pull request),\nwhich may mean something in the test are platform dependent?\n"},{"id":"545068","messageId":"CAHwyqnVBL9F8tYgDL7B-auFKHVXT3g=R=PQ-W1hp38P3qtqyEg@mail.gmail.com","threadId":"65574","inReplyTo":"xmqqa4t3ubwj.fsf@gitster.g","subject":"Re: [PATCH v13 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-09T13:20:04Z","receivedAt":"2026-06-09T13:20:42Z","isPatch":true,"body":"On Tue, Jun 9, 2026 at 2:38 PM Junio C Hamano <gitster@pobox.com> wrote:\n>\n> Harald Nordgren <haraldnordgren@gmail.com> writes:\n>\n> > The GitHub CI has been broken for some time, maybe I should have told\n> > you about this earlier, but it coincided with a period where other\n> > open source projects I worked on also had mass CI failures, so I\n> > chalked it up to upstream issues (GitHub, Linux, etc). But it seems to\n> > have not gone away.\n> >\n> > All of my GitHub pull requests have broken tests (see e.g. which a\n> > quite minimal change: https://github.com/git/git/pull/2313). This\n> > makes it harder to detect actual issues. But of course it's not an\n> > excuse.\n>\n> FWIW, the breakage was observed in my local testing, and that is why\n> I found it so disturbing.  Apparently you didn't see such breakages\n> that can be detected so easily during your local testing (otherwise\n> you wouldn't have pushed it out to update your GitHub pull request),\n> which may mean something in the test are platform dependent?\n\nNo, it was broken on my local machine as well. I was sloppy when I\npushed out v13 and didn't run tests locally.\n\nUsually I will push to my GitHub PR incrementally as I work on a new\nversion. I don’t necessarily keep the GitHub PR clean between\nsubmitting versions. I will diff against my latest version tag to see\nmy own progress, and if I mess it up I can always hard reset to the\nlatest version tag to start over from there.\n\nNormally, I would never push out a new version unless the GitHub CI\npasses. But it’s been broken for a month.\n\n\nHarald\n"},{"id":"545070","messageId":"1c56bf9c-8273-4499-87c8-16ddf0112dca@gmail.com","threadId":"65574","inReplyTo":"7ef9502e01055fd5442550cf34d491fd21a9b971.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-09T13:21:27Z","receivedAt":"2026-06-09T13:21:30Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add a warn-only mode to delete_branches() and check_branch_commit()\n> so a bulk caller can report branches that are not fully merged as a\n> short warning and carry on, rather than erroring with the longer\n> \"use 'git branch -D'\" advice that the plain \"git branch -d\" path\n> emits. Existing callers are unaffected.\n\nThere is no mention here of the conversion to use a flags argument which \nshould be a separate preparatory commit\n  > @@ -189,20 +189,33 @@ static int branch_merged(int kind, const char \n*name,\n>   \treturn merged;\n>   }\n>   \n> +enum delete_branch_flags {\n> +\tDELETE_BRANCH_FORCE = (1 << 0),\n> +\tDELETE_BRANCH_QUIET = (1 << 1),\n> +\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n> +};\n> +\n>   static int check_branch_commit(const char *branchname, const char *refname,\n>   \t\t\t       const struct object_id *oid, struct commit *head_rev,\n> -\t\t\t       int kinds, int force)\n> +\t\t\t       int kinds, unsigned int flags)\n>   {\n> +\tint force = flags & DELETE_BRANCH_FORCE;\n\nThis is missing \"!!\" to keep the value the same (alternatively we could \nperhaps convert \"force\" to a bool, though I haven't looked too closely \nat how it is used in the rest of the function). Apart from that this \ngood, unlike the conversion below it means the rest of the function sees \nthe same variables in the same state as it did before the conversion. It \nwould be a good idea to follow this pattern for the new flag.\n\n\tbool warn = flags & DELETE_BRANCH_WARN_ONLY;\n\nand then just use \"warn\" later. It is a common pattern in our code to \ntake a flags argument and split it out into various boolean variables at \nthe start of the function to avoid a lot of awkward bit masks in the \nmain body of the function.\n\n> @@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)\n>   \tstrbuf_release(&buf);\n>   }\n>   \n> -static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet)\n> +static int delete_branches(int argc, const char **argv, int kinds,\n> +\t\t\t   unsigned int flags)\n>   {\n>   \tstruct commit *head_rev = NULL;\n>   \tstruct object_id oid;\n> @@ -227,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \tint i;\n>   \tint ret = 0;\n>   \tint remote_branch = 0;\n> +\tint force, quiet;\n\nWe should initialize these here using flags so that the rest of the code \nsees the same variables and values as it did before the conversion.\n\n>   \tstruct strbuf bname = STRBUF_INIT;\n>   \tenum interpret_branch_kind allowed_interpret;\n>   \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n> @@ -241,7 +255,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\tremote_branch = 1;\n>   \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n>   \n> -\t\tforce = 1;\n> +\t\tflags |= DELETE_BRANCH_FORCE;\n>   \t\tbreak;\n>   \tcase FILTER_REFS_BRANCHES:\n>   \t\tfmt = \"refs/heads/%s\";\n> @@ -252,12 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t}\n>   \tbranch_name_pos = strcspn(fmt, \"%\");\n>   \n> +\tforce = flags & DELETE_BRANCH_FORCE;\n> +\tquiet = flags & DELETE_BRANCH_QUIET;\n> +\n>   \tif (!force)\n>   \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n>   \n>   \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n>   \t\tchar *target = NULL;\n> -\t\tint flags = 0;\n> +\t\tint ref_flags = 0;\n\nThis is sensible so we don't shadow the new function argument but this \nadded complexity is a good reason to split the flags change into its own \ncommit before adding the warning flag.\n\nI'll take a look at the other patches later this week - there is no need \nto send a new version before I've commented on the rest of the series.\n\nThanks\n\nPhillip\n\n>   \n>   \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n>   \t\tfree(name);\n> @@ -279,7 +296,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\t\t\t     RESOLVE_REF_READING\n>   \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n>   \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n> -\t\t\t\t\t     &oid, &flags);\n> +\t\t\t\t\t     &oid, &ref_flags);\n>   \t\tif (!target) {\n>   \t\t\tif (remote_branch) {\n>   \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n> @@ -291,7 +308,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n>   \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n>   \t\t\t\t\t\t\t\t\t   &oid,\n> -\t\t\t\t\t\t\t\t\t   &flags);\n> +\t\t\t\t\t\t\t\t\t   &ref_flags);\n>   \t\t\t\tFREE_AND_NULL(virtual_name);\n>   \n>   \t\t\t\tif (virtual_target)\n> @@ -306,16 +323,17 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\tcontinue;\n>   \t\t}\n>   \n> -\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n> +\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>   \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n> -\t\t\t\t\tforce)) {\n> -\t\t\tret = 1;\n> +\t\t\t\t\tflags)) {\n> +\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n> +\t\t\t\tret = 1;\n>   \t\t\tgoto next;\n>   \t\t}\n>   \n>   \t\titem = string_list_append(&refs_to_delete, name);\n> -\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n> -\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n> +\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n> +\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n>   \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n>   \n>   \tnext:\n> @@ -872,7 +890,9 @@ int cmd_branch(int argc,\n>   \tif (delete) {\n>   \t\tif (!argc)\n>   \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n> +\t\tret = delete_branches(argc, argv, filter.kind,\n> +\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n> +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>   \t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n\n"},{"id":"545536","messageId":"7e4d97d9-e21a-4f13-ab2c-ac1eb125271c@gmail.com","threadId":"65574","inReplyTo":"9924373da0a0598cabe4f08f3bc4200833679171.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-15T09:46:21Z","receivedAt":"2026-06-15T09:46:19Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> \tgit branch --prune-merged <branch>...\n\nPlease see my comments on the previous version about the naming of this \noption. I really think we need to start a discussion to find a better \nname for this option as the other options to delete a branch are named \n\"delete\" rather than \"prune\" and this does not remove the branches \nlisted by \"--merge\"\n\n> deletes the local branches that \"--forked <branch>\" would list,\n> keeping only those whose tip is reachable from their configured\n> upstream: the work has already landed on the upstream they track,\n> so the local copy is no longer needed.\n> \n> Reachability is read from local refs; nothing is fetched. Run\n> \"git fetch\" first if you want fresh upstream refs.\n\nI don't  think this sentence adds anything - git never fetches unless \nthe user explicitly asks it to.\n\n> \n> Three kinds of branches are spared:\n> \n>    * any branch checked out in any worktree;\n>    * any branch whose upstream no longer resolves locally, since a\n>      missing upstream is not by itself a sign of integration;\n>    * any branch whose push destination equals its upstream\n>      (<branch>@{push} is the same as <branch>@{upstream}), such as\n>      a local \"main\" that tracks and pushes to \"origin/main\". Right\n>      after a pull it just looks \"fully merged\", so it is left\n>      alone. Only branches that push somewhere other than their\n>      upstream, typically topics in a fork workflow, are candidates.\n> \n> Branches that are not yet merged into their upstream are reported\n> as a short warning and skipped, so one unmerged topic does not\n> abort the whole sweep.\n\nI'm not sure about this warning - the user has asked us to delete the \nbranches whose upstreams match those passed on the commandline and that \nhave been merged so do they really want to hear about the ones that have \nnot been merged? It might be useful to have a way to list those that \nhave not been merged in the future.\n\n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc |  24 ++++\n>   builtin/branch.c              |  67 +++++++++++-\n>   t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++\n>   3 files changed, 290 insertions(+), 2 deletions(-)\n> \n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index 62ebab6051..fdaccc9662 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n>   git branch (-c|-C) [<old-branch>] <new-branch>\n>   git branch (-d|-D) [-r] <branch-name>...\n>   git branch --edit-description [<branch-name>]\n> +git branch --prune-merged <branch>...\n>   \n>   DESCRIPTION\n>   -----------\n> @@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.\n>   \tPrint the name of the current branch. In detached `HEAD` state,\n>   \tnothing is printed.\n>   \n> +`--prune-merged <branch>...`::\n> +\tDelete the local branches that `--forked` would list for the\n> +\tgiven _<branch>_ arguments, but only those whose tip is\n> +\treachable from their configured upstream. In other words, the\n> +\twork on the branch has already landed on the upstream it\n> +\ttracks, so the local copy is no longer needed. Several\n> +\t_<branch>_ patterns may be given, e.g. `git branch\n> +\t--prune-merged origin/main 'feature*'`.\n> ++\n> +Reachability is checked against whatever the upstream refs say\n> +locally; nothing is fetched. Run `git fetch` first if you want\n> +the upstream refs refreshed.\nMaybe\n\nReachability is checked against the remote-tracking branch. Run `git \nfetch` first if you want update the remote-tracking branch.\n\n> ++\n> +A branch is left alone if any of the following holds:\n\ns/left alone/not deleted/\n\n> +its upstream no longer resolves locally; it is checked out in any\n\ns/upstream no longer resolves locally/upstream remote-tracking branch no \nlonger exists/\n\n> +worktree; or its push destination (`<branch>@{push}`) equals its\n> +upstream (`<branch>@{upstream}`), so it cannot be distinguished\n> +from a freshly pulled trunk that just looks \"fully merged\".\n\nWhat's a \"freshly pulled trunk\"? \"trunk\" does not appear in gitglossary(7)\n\n> ++\n> +Branches refused by the \"fully merged\" safety check are listed as\n> +warnings and skipped; pass them to `git branch -D` explicitly if\n> +you want them gone.\n\ns/them gone/to delete them/\n\n> +\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 2cc5a8cde0..af37a0ceb7 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n>   \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n> +\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n>   \tNULL\n>   };\n>   \n> @@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n>   \treturn 0;\n>   }\n>   \n> +static int prune_merged_branches(int argc, const char **argv,\n> +\t\t\t\t int quiet)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct ref_filter filter = REF_FILTER_INIT;\n> +\tstruct ref_array candidates;\n> +\tstruct strvec deletable = STRVEC_INIT;\n> +\tint i, ret = 0;\n> +\n> +\tif (!argc)\n> +\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n> +\n> +\tfor (i = 0; i < argc; i++)\n> +\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n> +\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n> +\n> +\tfilter.kind = FILTER_REFS_BRANCHES;\n> +\tmemset(&candidates, 0, sizeof(candidates));\n\nIt would be nicer to add \"= { 0 }\" to the declaration of candidates above.\n\n> +\tfilter_refs(&candidates, &filter, filter.kind);\n> +\n> +\tfor (i = 0; i < candidates.nr; i++) {\n> +\t\tconst char *full_name = candidates.items[i]->refname;\n> +\t\tconst char *short_name;\n> +\t\tstruct branch *branch;\n> +\t\tconst char *upstream, *push;\n> +\n> +\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n> +\t\t\tcontinue;\n\nIf we've set filter.kind = FILTER_REFS_BRANCHS how can this condition fail?\n\n> +\t\tif (branch_checked_out(full_name))\n> +\t\t\tcontinue;\n> +\n> +\t\tbranch = branch_get(short_name);\n> +\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n\nHow can branch be NULL? Don't we require branch_get() to succeed in \norder to filter it?\n\n> +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n> +\t\t\tcontinue;\n> +\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n> +\t\tif (!push || !strcmp(push, upstream))\n> +\t\t\tcontinue;\n\nBy the time we've reached this point we know that \nbranch@{upstream}exists and does not match branch@{push} - good\n\n> +\t\tstrvec_push(&deletable, short_name);\n> +\t}\n> +\n> +\tif (deletable.nr)\n> +\t\tret = delete_branches(deletable.nr, deletable.v,\n> +\t\t\t\t      FILTER_REFS_BRANCHES,\n> +\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n> +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n> +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n\nHere we delete the branches - good.\n> +\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n> +\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n\ns/is/are/\n\nSorry I didn't get round to reviewing these last week, I'll try and take \na look at the tests and the other patches tomorrow\n\nThanks\n\nPhillip\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 4e7deddc04..27ea1319bb 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n>   \ttest_grep \"requires a value\" err\n>   '\n>   \n> +test_expect_success '--prune-merged: setup' '\n> +\ttest_create_repo pm-upstream &&\n> +\ttest_commit -C pm-upstream base &&\n> +\tgit -C pm-upstream checkout -b next &&\n> +\ttest_commit -C pm-upstream one-commit &&\n> +\ttest_commit -C pm-upstream two-commit &&\n> +\tgit -C pm-upstream branch one HEAD~ &&\n> +\tgit -C pm-upstream branch two HEAD &&\n> +\tgit -C pm-upstream branch wip main &&\n> +\tgit -C pm-upstream checkout main &&\n> +\ttest_create_repo pm-fork\n> +'\n> +\n> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> +\ttest_when_finished \"rm -rf pm-merged\" &&\n> +\tgit clone pm-upstream pm-merged &&\n> +\tgit -C pm-merged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-merged remote.pushDefault fork &&\n> +\ttest_config -C pm-merged push.default current &&\n> +\tgit -C pm-merged branch one one-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-merged branch two two-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n> +\n> +\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged accepts a literal upstream' '\n> +\ttest_when_finished \"rm -rf pm-literal\" &&\n> +\tgit clone pm-upstream pm-literal &&\n> +\tgit -C pm-literal remote add fork ../pm-fork &&\n> +\ttest_config -C pm-literal remote.pushDefault fork &&\n> +\ttest_config -C pm-literal push.default current &&\n> +\tgit -C pm-literal branch one one-commit &&\n> +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-literal branch --prune-merged origin/next &&\n> +\n> +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-union\" &&\n> +\tgit clone pm-upstream pm-union &&\n> +\tgit -C pm-union remote add fork ../pm-fork &&\n> +\ttest_config -C pm-union remote.pushDefault fork &&\n> +\ttest_config -C pm-union push.default current &&\n> +\tgit -C pm-union branch one one-commit &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-union branch two base &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n> +\tgit -C pm-union checkout --detach &&\n> +\n> +\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n> +\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged accepts a local upstream' '\n> +\ttest_when_finished \"rm -rf pm-local\" &&\n> +\tgit clone pm-upstream pm-local &&\n> +\tgit -C pm-local remote add fork ../pm-fork &&\n> +\ttest_config -C pm-local remote.pushDefault fork &&\n> +\ttest_config -C pm-local push.default current &&\n> +\tgit -C pm-local checkout -b trunk &&\n> +\tgit -C pm-local branch one one-commit &&\n> +\tgit -C pm-local branch --set-upstream-to=trunk one &&\n> +\tgit -C pm-local merge --ff-only one-commit &&\n> +\n> +\tgit -C pm-local branch --prune-merged trunk &&\n> +\n> +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n> +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n> +\tgit clone pm-upstream pm-unmerged &&\n> +\tgit -C pm-unmerged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-unmerged remote.pushDefault fork &&\n> +\ttest_config -C pm-unmerged push.default current &&\n> +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n> +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-unmerged local-only &&\n> +\tgit -C pm-unmerged checkout - &&\n> +\n> +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n> +\ttest_grep \"not fully merged\" err &&\n> +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n> +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n> +'\n> +\n> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n> +\ttest_when_finished \"rm -rf pm-nohead\" &&\n> +\tgit clone pm-upstream pm-nohead &&\n> +\tgit -C pm-nohead remote add fork ../pm-fork &&\n> +\ttest_config -C pm-nohead remote.pushDefault fork &&\n> +\ttest_config -C pm-nohead push.default current &&\n> +\tgit -C pm-nohead branch topic one-commit &&\n> +\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n> +\n> +\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n> +\n> +\ttest_grep ! \"not yet merged to HEAD\" err &&\n> +\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n> +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n> +\tgit clone pm-upstream pm-upstream-gone &&\n> +\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n> +\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n> +\ttest_config -C pm-upstream-gone push.default current &&\n> +\tgit -C pm-upstream-gone branch one one-commit &&\n> +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n> +\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged never deletes the checked-out branch' '\n> +\ttest_when_finished \"rm -rf pm-head\" &&\n> +\tgit clone pm-upstream pm-head &&\n> +\tgit -C pm-head remote add fork ../pm-fork &&\n> +\ttest_config -C pm-head remote.pushDefault fork &&\n> +\ttest_config -C pm-head push.default current &&\n> +\tgit -C pm-head checkout -b one one-commit &&\n> +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-head rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged spares branches that push back to their upstream' '\n> +\ttest_when_finished \"rm -rf pm-push-eq\" &&\n> +\tgit clone pm-upstream pm-push-eq &&\n> +\tgit -C pm-push-eq checkout --detach &&\n> +\n> +\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n> +\ttest_when_finished \"rm -rf pm-push-branch\" &&\n> +\tgit clone pm-upstream pm-push-branch &&\n> +\tgit -C pm-push-branch remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-branch remote.pushDefault fork &&\n> +\ttest_config -C pm-push-branch push.default current &&\n> +\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n> +\tgit -C pm-push-branch checkout --detach &&\n> +\n> +\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n> +\ttest_when_finished \"rm -rf pm-push-diff\" &&\n> +\tgit clone pm-upstream pm-push-diff &&\n> +\tgit -C pm-push-diff remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-diff remote.pushDefault fork &&\n> +\ttest_config -C pm-push-diff push.default current &&\n> +\tgit -C pm-push-diff branch topic one-commit &&\n> +\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n> +\tgit -C pm-push-diff checkout --detach &&\n> +\n> +\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged requires at least one <branch>' '\n> +\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n> +\ttest_grep \"requires at least one <branch>\" err\n> +'\n> +\n> +test_expect_success '--prune-merged takes positional <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-positional\" &&\n> +\tgit clone pm-upstream pm-positional &&\n> +\tgit -C pm-positional remote add fork ../pm-fork &&\n> +\ttest_config -C pm-positional remote.pushDefault fork &&\n> +\ttest_config -C pm-positional push.default current &&\n> +\tgit -C pm-positional branch one one-commit &&\n> +\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-positional branch two base &&\n> +\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n> +\tgit -C pm-positional checkout --detach &&\n> +\n> +\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n> +\n> +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n> +'\n> +\n>   test_done\n\n"},{"id":"545537","messageId":"16bb2c32-224d-4c8a-a696-d0b9ca9d304c@gmail.com","threadId":"65574","inReplyTo":"7383872f4b2f422ec36b11ab5fb31cce08e6106a.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 1/6] branch: add --forked filter for --list mode","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-15T09:46:45Z","receivedAt":"2026-06-15T09:46:44Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add a --forked option to \"git branch\" list mode that lists only\n> branches whose configured upstream matches <branch>. The argument\n> can be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n> (e.g. \"origin/*\"), and may be repeated to widen the filter.\n> \n> It is an ordinary list filter, so it combines with the others:\n> \n>      git branch --merged origin/main --forked 'origin/*'\n> \n> lists branches forked from origin that are already merged into\n> origin/main, and --no-merged inverts the question.\n> \n> This is the building block for --prune-merged, which deletes the\n> listed branches once they have landed on their upstream.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc | 10 +++-\n>   builtin/branch.c              | 18 ++++++-\n>   ref-filter.c                  | 70 ++++++++++++++++++++++++++\n>   ref-filter.h                  | 10 ++++\n>   t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++\n>   5 files changed, 197 insertions(+), 3 deletions(-)\n\nIt's nice to see that moving the code into the ref-filter.c has reduced \nthe overall number of additions by ~50 lines. The documentation and \nimplementation look fine though I have a couple of thoughts:\n\n  - Previous iterations supported \"origin\" as a short hand for the branch\n    origin/HEAD points to. That was nice because it means we can use the\n    same syntax for \"git checkout -b\" and \"git branch --forked\". It\n    would probably be a good idea to support it.\n\n  - We could probably be a bit smarter about the way we handle patterns\n    by copying what dwim_ref() does to support things like\n    remotes/origin/* but I don't think we need to do that now.\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index e7829c2c4b..4e7deddc04 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--forked: setup' '\n> +\ttest_create_repo forked-upstream &&\n> +\ttest_commit -C forked-upstream base &&\n> +\tgit -C forked-upstream branch one base &&\n> +\tgit -C forked-upstream branch two base &&\n> +\n> +\ttest_create_repo forked-other &&\n> +\ttest_commit -C forked-other other-base &&\n> +\tgit -C forked-other branch foreign other-base &&\n> +\n> +\tgit clone forked-upstream forked &&\n> +\tgit -C forked remote add other ../forked-other &&\n\nWe can use \"add -f\" to fetch here rather than doing it separately.\n\n> +\tgit -C forked fetch other &&\n> +\tgit -C forked branch local-base &&\n> +\tgit -C forked branch --track local-one origin/one &&\n> +\tgit -C forked branch --track local-two origin/two &&\n> +\tgit -C forked branch --track local-foreign other/foreign &&\n> +\tgit -C forked branch detached &&\n\nNormally we use \"detached\" to mean no branch, lets read on and see how \nthis is used ...\n\n> +\tgit -C forked branch --track local-trunk local-base\n> +'\n> +\n> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n> +\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n\norigin/one and origin/two point to the same commit, so this demonstrates \nthat we're checking the branch names, not the topology which is good. \nAll of the local branches point at their upstream which isn't very \nrealistic - I wonder if we should add some local commits?\n\nThe tests all look sensible, but there is no coverage for combining \n--forked with branch names as in\n\n     git branch --forked <arg> <branch>\n\nThanks\n\nPhillip\n\n\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked <glob> filters by wildmatch' '\n> +\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-one\n> +\tlocal-two\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n> +\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n> +\techo local-trunk >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked can be repeated to widen the filter' '\n> +\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-one\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked combines literal and glob arguments' '\n> +\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-trunk\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n> +\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-foreign\n> +\tlocal-one\n> +\tlocal-two\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked composes with --no-merged' '\n> +\ttest_when_finished \"git -C forked checkout detached\" &&\n> +\tgit -C forked checkout local-one &&\n> +\ttest_commit -C forked local-only &&\n> +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n> +\t\t--format=\"%(refname:short)\" >actual &&\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked rejects unknown branch/pattern' '\n> +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n> +\ttest_grep \"not a valid branch or pattern\" err\n> +'\n> +\n> +test_expect_success '--forked requires a value' '\n> +\ttest_must_fail git -C forked branch --forked 2>err &&\n> +\ttest_grep \"requires a value\" err\n> +'\n> +\n>   test_done\n\n"},{"id":"545538","messageId":"3b04055c-846d-4aaa-99bb-5da932172f75@gmail.com","threadId":"65574","inReplyTo":"259113e304c4085c2bd90cce3a40c965744d5a00.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-15T09:47:01Z","receivedAt":"2026-06-15T09:46:59Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n>\n> @@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n>   \tint i;\n>   \tint ret = 0;\n>   \tint remote_branch = 0;\n> -\tint force, quiet;\n> +\tint force, quiet, dry_run, no_head_fallback;\n\nAs with the previous patch it would be safer to initialize the new \nvariables where they are declared.\n\n>   \tfor_each_string_list_item(item, &refs_to_delete) {\n>   \t\tchar *describe_ref = item->util;\n>   \t\tchar *name = item->string;\n> -\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n> +\t\tif (dry_run) {\n> +\t\t\tif (!quiet)\n> +\t\t\t\tprintf(remote_branch\n> +\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n> +\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n\nI wondered what the \"was %s\" was about but it prints the symref target \nor oid of the ref.\n\nThanks\n\nPhillip\n\n> +\t\t\t\t\tname + branch_name_pos, describe_ref);\n> +\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n>   \t\t\tchar *refname = name + branch_name_pos;\n>   \t\t\tif (!quiet)\n>   \t\t\t\tprintf(remote_branch\n\n"},{"id":"545592","messageId":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com","subject":"[PATCH v15 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:15Z","receivedAt":"2026-06-15T16:47:25Z","isPatch":true,"body":" * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * --forked now accepts a bare remote name (e.g. origin) for the branch\n   origin/HEAD points at using DWIM.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked <pattern> <branch> coverage), renamed the misleading trunk\n   fixture, and replaced the misnamed detached branch with git checkout\n   --detach.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  43 +++-\n builtin/branch.c                 | 184 ++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 387 +++++++++++++++++++++++++++++++\n 6 files changed, 673 insertions(+), 28 deletions(-)\n\n\nbase-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v15\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v15\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v14:\n\n 1:  7383872f4b ! 1:  da741b5ea7 branch: add --forked filter for --list mode\n     @@ Commit message\n      \n          Add a --forked option to \"git branch\" list mode that lists only\n          branches whose configured upstream matches <branch>. The argument\n     -    can be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n     +    can be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n     +    \"origin\" for the branch its origin/HEAD points at, or a shell glob\n          (e.g. \"origin/*\"), and may be repeated to widen the filter.\n      \n          It is an ordinary list filter, so it combines with the others:\n     @@ Commit message\n          lists branches forked from origin that are already merged into\n          origin/main, and --no-merged inverts the question.\n      \n     -    This is the building block for --prune-merged, which deletes the\n     +    This is the building block for --delete-merged, which deletes the\n          listed branches once they have landed on their upstream.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-branch.adoc: superproject's \"origin/main\", but tracks the subm\n      +`--forked <branch>`::\n      +\tOnly list branches whose configured upstream matches\n      +\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n     -+\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n     -+\toption can be repeated to widen the filter. Implies `--list`.\n     ++\t`master`), a remote name like `origin` for the branch its\n     ++\t`origin/HEAD` points at, or a shell-style glob (e.g.\n     ++\t`'origin/*'`). The option can be repeated to widen the\n     ++\tfilter. Implies `--list`.\n      +\n       `--points-at <object>`::\n       \tOnly list branches of _<object>_.\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tgit -C forked-other branch foreign other-base &&\n      +\n      +\tgit clone forked-upstream forked &&\n     -+\tgit -C forked remote add other ../forked-other &&\n     -+\tgit -C forked fetch other &&\n     ++\tgit -C forked remote add -f other ../forked-other &&\n     ++\tgit -C forked remote set-head origin one &&\n      +\tgit -C forked branch local-base &&\n      +\tgit -C forked branch --track local-one origin/one &&\n      +\tgit -C forked branch --track local-two origin/two &&\n      +\tgit -C forked branch --track local-foreign other/foreign &&\n     -+\tgit -C forked branch detached &&\n     -+\tgit -C forked branch --track local-trunk local-base\n     ++\tgit -C forked branch --track local-onbase local-base &&\n     ++\n     ++\tgit -C forked checkout local-one &&\n     ++\ttest_commit -C forked --no-tag local-one-work local-one.t &&\n     ++\tgit -C forked checkout local-foreign &&\n     ++\ttest_commit -C forked --no-tag local-foreign-work local-foreign.t &&\n     ++\tgit -C forked checkout --detach\n      +'\n      +\n      +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\n      +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n      +\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n     -+\techo local-trunk >expect &&\n     ++\techo local-onbase >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n     -+\tlocal-trunk\n     ++\tlocal-onbase\n      +\tEOF\n      +\ttest_cmp expect actual\n      +'\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked composes with --no-merged' '\n     -+\ttest_when_finished \"git -C forked checkout detached\" &&\n     ++\ttest_when_finished \"git -C forked checkout --detach\" &&\n      +\tgit -C forked checkout local-one &&\n      +\ttest_commit -C forked local-only &&\n      +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\ttest_must_fail git -C forked branch --forked 2>err &&\n      +\ttest_grep \"requires a value\" err\n      +'\n     ++\n     ++test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n     ++\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n     ++\techo local-one >expect &&\n     ++\ttest_cmp expect actual\n     ++'\n     ++\n     ++test_expect_success '--forked narrows a <pattern> argument' '\n     ++\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tlocal-one\n     ++\tlocal-two\n     ++\tEOF\n     ++\ttest_cmp expect actual\n     ++'\n      +\n       test_done\n 2:  7ef9502e01 ! 2:  91c35f10cc branch: let delete_branches warn instead of error on bulk refusal\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: let delete_branches warn instead of error on bulk refusal\n     +    branch: convert delete_branches() to a flags argument\n      \n     -    Add a warn-only mode to delete_branches() and check_branch_commit()\n     -    so a bulk caller can report branches that are not fully merged as a\n     -    short warning and carry on, rather than erroring with the longer\n     -    \"use 'git branch -D'\" advice that the plain \"git branch -d\" path\n     -    emits. Existing callers are unaffected.\n     +    delete_branches() and check_branch_commit() take a pair of int\n     +    booleans (force and quiet) that the next commits would grow further.\n     +    Replace them with a single \"unsigned int flags\" argument and an\n     +    enum, splitting the bits back into named bool locals so the body\n     +    keeps reading the same named values.\n     +\n     +    No change in behavior.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n      +enum delete_branch_flags {\n      +\tDELETE_BRANCH_FORCE = (1 << 0),\n      +\tDELETE_BRANCH_QUIET = (1 << 1),\n     -+\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n      +};\n      +\n       static int check_branch_commit(const char *branchname, const char *refname,\n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n      -\t\t\t       int kinds, int force)\n      +\t\t\t       int kinds, unsigned int flags)\n       {\n     -+\tint force = flags & DELETE_BRANCH_FORCE;\n     ++\tbool force = flags & DELETE_BRANCH_FORCE;\n       \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n       \tif (!force && !rev) {\n       \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n     - \t\treturn -1;\n     - \t}\n     - \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     --\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n     --\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     --\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     --\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\tif (flags & DELETE_BRANCH_WARN_ONLY) {\n     -+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n     -+\t\t\t\tbranchname);\n     -+\t\t} else {\n     -+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n     -+\t\t\t      branchname);\n     -+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     -+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n     -+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\t}\n     - \t\treturn -1;\n     - \t}\n     - \treturn 0;\n      @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       \tstrbuf_release(&buf);\n       }\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \tint i;\n       \tint ret = 0;\n       \tint remote_branch = 0;\n     -+\tint force, quiet;\n     ++\tbool force;\n     ++\tbool quiet = flags & DELETE_BRANCH_QUIET;\n       \tstruct strbuf bname = STRBUF_INIT;\n       \tenum interpret_branch_kind allowed_interpret;\n       \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \tbranch_name_pos = strcspn(fmt, \"%\");\n       \n      +\tforce = flags & DELETE_BRANCH_FORCE;\n     -+\tquiet = flags & DELETE_BRANCH_QUIET;\n      +\n       \tif (!force)\n       \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      +\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n       \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n      -\t\t\t\t\tforce)) {\n     --\t\t\tret = 1;\n      +\t\t\t\t\tflags)) {\n     -+\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n     -+\t\t\t\tret = 1;\n     + \t\t\tret = 1;\n       \t\t\tgoto next;\n       \t\t}\n       \n -:  ---------- > 3:  e101dd2886 branch: let delete_branches skip unmerged branches on bulk refusal\n 3:  259113e304 ! 4:  6c3534901a branch: prepare delete_branches for a bulk caller\n     @@ Commit message\n          branch: prepare delete_branches for a bulk caller\n      \n          Teach delete_branches() two new modes for the upcoming\n     -    --prune-merged: one that asks only whether a branch is merged into\n     +    --delete-merged: one that asks only whether a branch is merged into\n          its upstream, without falling back to HEAD when there is no\n          upstream, and one that rehearses the deletions without removing any\n          ref. Existing callers keep their current behavior.\n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n      @@ builtin/branch.c: enum delete_branch_flags {\n       \tDELETE_BRANCH_FORCE = (1 << 0),\n       \tDELETE_BRANCH_QUIET = (1 << 1),\n     - \tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n     + \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n      +\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n      +\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n       };\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \tint i;\n     - \tint ret = 0;\n     - \tint remote_branch = 0;\n     --\tint force, quiet;\n     -+\tint force, quiet, dry_run, no_head_fallback;\n     + \tbool force;\n     + \tbool quiet = flags & DELETE_BRANCH_QUIET;\n     + \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     ++\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n     ++\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n       \tstruct strbuf bname = STRBUF_INIT;\n       \tenum interpret_branch_kind allowed_interpret;\n       \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n       \n       \tforce = flags & DELETE_BRANCH_FORCE;\n     - \tquiet = flags & DELETE_BRANCH_QUIET;\n     -+\tdry_run = flags & DELETE_BRANCH_DRY_RUN;\n     -+\tno_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n       \n      -\tif (!force)\n      +\tif (!force && !no_head_fallback)\n 4:  9924373da0 ! 5:  5899013b8f branch: add --prune-merged <branch>\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --prune-merged <branch>\n     +    branch: add --delete-merged <branch>\n      \n     -            git branch --prune-merged <branch>...\n     +            git branch --delete-merged <branch>...\n      \n          deletes the local branches that \"--forked <branch>\" would list,\n          keeping only those whose tip is reachable from their configured\n     -    upstream: the work has already landed on the upstream they track,\n     +    upstream. The work has already landed on the upstream they track,\n          so the local copy is no longer needed.\n      \n     -    Reachability is read from local refs; nothing is fetched. Run\n     -    \"git fetch\" first if you want fresh upstream refs.\n     +    Three kinds of branches are not deleted:\n      \n     -    Three kinds of branches are spared:\n     -\n     -      * any branch checked out in any worktree;\n     -      * any branch whose upstream no longer resolves locally, since a\n     -        missing upstream is not by itself a sign of integration;\n     +      * any branch checked out in any worktree\n     +      * any branch whose upstream remote-tracking branch no longer\n     +        exists, since a missing upstream is not by itself a sign of\n     +        integration\n            * any branch whose push destination equals its upstream\n              (<branch>@{push} is the same as <branch>@{upstream}), such as\n              a local \"main\" that tracks and pushes to \"origin/main\". Right\n     -        after a pull it just looks \"fully merged\", so it is left\n     -        alone. Only branches that push somewhere other than their\n     -        upstream, typically topics in a fork workflow, are candidates.\n     +        after a pull it just looks \"fully merged\", so it is kept. Only\n     +        branches that push somewhere other than their upstream,\n     +        typically topics in a fork workflow, are candidates.\n      \n     -    Branches that are not yet merged into their upstream are reported\n     -    as a short warning and skipped, so one unmerged topic does not\n     -    abort the whole sweep.\n     +    A branch whose work is not yet merged into its upstream is silently\n     +    skipped, so one unmerged topic does not abort the whole sweep.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     -+git branch --prune-merged <branch>...\n     ++git branch --delete-merged <branch>...\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n       \tPrint the name of the current branch. In detached `HEAD` state,\n       \tnothing is printed.\n       \n     -+`--prune-merged <branch>...`::\n     ++`--delete-merged <branch>...`::\n      +\tDelete the local branches that `--forked` would list for the\n      +\tgiven _<branch>_ arguments, but only those whose tip is\n      +\treachable from their configured upstream. In other words, the\n      +\twork on the branch has already landed on the upstream it\n      +\ttracks, so the local copy is no longer needed. Several\n      +\t_<branch>_ patterns may be given, e.g. `git branch\n     -+\t--prune-merged origin/main 'feature*'`.\n     ++\t--delete-merged origin/main 'feature*'`.\n      ++\n     -+Reachability is checked against whatever the upstream refs say\n     -+locally; nothing is fetched. Run `git fetch` first if you want\n     -+the upstream refs refreshed.\n     ++A branch is not deleted when:\n      ++\n     -+A branch is left alone if any of the following holds:\n     -+its upstream no longer resolves locally; it is checked out in any\n     -+worktree; or its push destination (`<branch>@{push}`) equals its\n     -+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n     -+from a freshly pulled trunk that just looks \"fully merged\".\n     ++--\n     ++* its upstream remote-tracking branch no longer exists,\n     ++* it is checked out in any worktree, or\n     ++* its push destination (`<branch>@{push}`) equals its upstream\n     ++  (`<branch>@{upstream}`), so it cannot be distinguished from a\n     ++  branch that just looks \"fully merged\" right after a pull.\n     ++--\n      ++\n     -+Branches refused by the \"fully merged\" safety check are listed as\n     -+warnings and skipped; pass them to `git branch -D` explicitly if\n     -+you want them gone.\n     ++A branch whose work has not yet been merged into its upstream is\n     ++silently skipped. Delete it with `git branch -D` if you want to\n     ++remove it anyway.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n     ++\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n       \tNULL\n       };\n       \n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n       \treturn 0;\n       }\n       \n     -+static int prune_merged_branches(int argc, const char **argv,\n     ++static int delete_merged_branches(int argc, const char **argv,\n      +\t\t\t\t int quiet)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n     -+\tstruct ref_array candidates;\n     ++\tstruct ref_array candidates = { 0 };\n      +\tstruct strvec deletable = STRVEC_INIT;\n      +\tint i, ret = 0;\n      +\n      +\tif (!argc)\n     -+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n     ++\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n      +\n      +\tfor (i = 0; i < argc; i++)\n      +\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n      +\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n      +\n      +\tfilter.kind = FILTER_REFS_BRANCHES;\n     -+\tmemset(&candidates, 0, sizeof(candidates));\n      +\tfilter_refs(&candidates, &filter, filter.kind);\n      +\n      +\tfor (i = 0; i < candidates.nr; i++) {\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\tconst char *upstream, *push;\n      +\n      +\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     -+\t\t\tcontinue;\n     ++\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n      +\t\tif (branch_checked_out(full_name))\n      +\t\t\tcontinue;\n      +\n      +\t\tbranch = branch_get(short_name);\n     -+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n     ++\t\tupstream = branch_get_upstream(branch, NULL);\n      +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n      +\t\t\tcontinue;\n     -+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n     ++\t\tpush = branch_get_push(branch, NULL);\n      +\t\tif (!push || !strcmp(push, upstream))\n      +\t\t\tcontinue;\n      +\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tif (deletable.nr)\n      +\t\tret = delete_branches(deletable.nr, deletable.v,\n      +\t\t\t\t      FILTER_REFS_BRANCHES,\n     -+\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n     ++\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n      +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n      +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n      +\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t/* possible actions */\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     -+\tint prune_merged = 0;\n     ++\tint delete_merged = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n       \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n     -+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     -+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n     ++\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n     ++\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n      -\t    !show_current && !unset_upstream && argc == 0)\n     -+\t    !show_current && !unset_upstream && !prune_merged &&\n     ++\t    !show_current && !unset_upstream && !delete_merged &&\n      +\t    argc == 0)\n       \t\tlist = 1;\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n       \t\t\t    !!show_current + !!list + !!edit_description +\n      -\t\t\t    !!unset_upstream;\n     -+\t\t\t    !!unset_upstream + !!prune_merged;\n     ++\t\t\t    !!unset_upstream + !!delete_merged;\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n       \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n     -+\t} else if (prune_merged) {\n     -+\t\tret = prune_merged_branches(argc, argv, quiet);\n     ++\t} else if (delete_merged) {\n     ++\t\tret = delete_merged_branches(argc, argv, quiet);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n       \t\tret = 0;\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n     - \ttest_grep \"requires a value\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n     + \ttest_cmp expect actual\n       '\n       \n     -+test_expect_success '--prune-merged: setup' '\n     ++test_expect_success '--delete-merged: setup' '\n      +\ttest_create_repo pm-upstream &&\n      +\ttest_commit -C pm-upstream base &&\n      +\tgit -C pm-upstream checkout -b next &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\ttest_create_repo pm-fork\n      +'\n      +\n     -+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n     ++test_expect_success '--delete-merged deletes branches integrated into upstream' '\n      +\ttest_when_finished \"rm -rf pm-merged\" &&\n      +\tgit clone pm-upstream pm-merged &&\n      +\tgit -C pm-merged remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-merged branch two two-commit &&\n      +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n      +\n     -+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-merged branch --delete-merged \"origin/*\" &&\n      +\n      +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged accepts a literal upstream' '\n     ++test_expect_success '--delete-merged accepts a literal upstream' '\n      +\ttest_when_finished \"rm -rf pm-literal\" &&\n      +\tgit clone pm-upstream pm-literal &&\n      +\tgit -C pm-literal remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-literal branch one one-commit &&\n      +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-literal branch --prune-merged origin/next &&\n     ++\tgit -C pm-literal branch --delete-merged origin/next &&\n      +\n      +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n     ++test_expect_success '--delete-merged unions multiple <branch> arguments' '\n      +\ttest_when_finished \"rm -rf pm-union\" &&\n      +\tgit clone pm-upstream pm-union &&\n      +\tgit -C pm-union remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n      +\tgit -C pm-union checkout --detach &&\n      +\n     -+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n     ++\tgit -C pm-union branch --delete-merged origin/next origin/main &&\n      +\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged accepts a local upstream' '\n     ++test_expect_success '--delete-merged accepts a local upstream' '\n      +\ttest_when_finished \"rm -rf pm-local\" &&\n      +\tgit clone pm-upstream pm-local &&\n      +\tgit -C pm-local remote add fork ../pm-fork &&\n      +\ttest_config -C pm-local remote.pushDefault fork &&\n      +\ttest_config -C pm-local push.default current &&\n     -+\tgit -C pm-local checkout -b trunk &&\n     ++\tgit -C pm-local checkout -b mainline &&\n      +\tgit -C pm-local branch one one-commit &&\n     -+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n     ++\tgit -C pm-local branch --set-upstream-to=mainline one &&\n      +\tgit -C pm-local merge --ff-only one-commit &&\n      +\n     -+\tgit -C pm-local branch --prune-merged trunk &&\n     ++\tgit -C pm-local branch --delete-merged mainline &&\n      +\n      +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n     ++test_expect_success '--delete-merged silently skips un-integrated commits' '\n      +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n      +\tgit clone pm-upstream pm-unmerged &&\n      +\tgit -C pm-unmerged remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\ttest_commit -C pm-unmerged local-only &&\n      +\tgit -C pm-unmerged checkout - &&\n      +\n     -+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n     -+\ttest_grep \"not fully merged\" err &&\n     -+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n     ++\tgit -C pm-unmerged branch --delete-merged \"origin/*\" 2>err &&\n     ++\ttest_grep ! \"not fully merged\" err &&\n      +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n      +'\n      +\n     -+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n     ++test_expect_success '--delete-merged is silent about not-merged-to-HEAD' '\n      +\ttest_when_finished \"rm -rf pm-nohead\" &&\n      +\tgit clone pm-upstream pm-nohead &&\n      +\tgit -C pm-nohead remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-nohead branch topic one-commit &&\n      +\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n      +\n     -+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n     ++\tgit -C pm-nohead branch --delete-merged \"origin/*\" 2>err &&\n      +\n      +\ttest_grep ! \"not yet merged to HEAD\" err &&\n      +\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n      +'\n      +\n     -+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n     ++test_expect_success '--delete-merged skips branches whose upstream is gone' '\n      +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n      +\tgit clone pm-upstream pm-upstream-gone &&\n      +\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n      +\n      +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n     -+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-upstream-gone branch --delete-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged never deletes the checked-out branch' '\n     ++test_expect_success '--delete-merged never deletes the checked-out branch' '\n      +\ttest_when_finished \"rm -rf pm-head\" &&\n      +\tgit clone pm-upstream pm-head &&\n      +\tgit -C pm-head remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-head checkout -b one one-commit &&\n      +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n      +\n     -+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-head branch --delete-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-head rev-parse --verify refs/heads/one\n      +'\n      +\n     -+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n     ++test_expect_success '--delete-merged spares branches that push back to their upstream' '\n      +\ttest_when_finished \"rm -rf pm-push-eq\" &&\n      +\tgit clone pm-upstream pm-push-eq &&\n      +\tgit -C pm-push-eq checkout --detach &&\n      +\n     -+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-push-eq branch --delete-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n      +'\n      +\n     -+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n     ++test_expect_success '--delete-merged spares a per-branch pushRemote==upstream remote' '\n      +\ttest_when_finished \"rm -rf pm-push-branch\" &&\n      +\tgit clone pm-upstream pm-push-branch &&\n      +\tgit -C pm-push-branch remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n      +\tgit -C pm-push-branch checkout --detach &&\n      +\n     -+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-push-branch branch --delete-merged \"origin/*\" &&\n      +\n      +\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n      +'\n      +\n     -+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n     ++test_expect_success '--delete-merged prunes when @{push} differs from @{upstream}' '\n      +\ttest_when_finished \"rm -rf pm-push-diff\" &&\n      +\tgit clone pm-upstream pm-push-diff &&\n      +\tgit -C pm-push-diff remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n      +\tgit -C pm-push-diff checkout --detach &&\n      +\n     -+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n     ++\tgit -C pm-push-diff branch --delete-merged \"origin/*\" &&\n      +\n      +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n      +'\n      +\n     -+test_expect_success '--prune-merged requires at least one <branch>' '\n     -+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n     ++test_expect_success '--delete-merged requires at least one <branch>' '\n     ++\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n      +\ttest_grep \"requires at least one <branch>\" err\n      +'\n      +\n     -+test_expect_success '--prune-merged takes positional <branch> arguments' '\n     ++test_expect_success '--delete-merged takes positional <branch> arguments' '\n      +\ttest_when_finished \"rm -rf pm-positional\" &&\n      +\tgit clone pm-upstream pm-positional &&\n      +\tgit -C pm-positional remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n      +\tgit -C pm-positional checkout --detach &&\n      +\n     -+\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n     ++\tgit -C pm-positional branch --delete-merged origin/next origin/main &&\n      +\n      +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n 5:  d691d5051b ! 6:  72aaca0666 branch: add branch.<name>.pruneMerged opt-out\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add branch.<name>.pruneMerged opt-out\n     +    branch: add branch.<name>.deleteMerged opt-out\n      \n     -    Setting branch.<name>.pruneMerged=false exempts that branch from\n     -    \"git branch --prune-merged\", which is useful for a topic you want\n     +    Setting branch.<name>.deleteMerged=false exempts that branch from\n     +    \"git branch --delete-merged\", which is useful for a topic you want\n          to keep developing after an early round of it has been merged\n          upstream. Unless --quiet is given, each skip is reported so the\n          user knows why their topic was kept.\n     @@ Documentation/config/branch.adoc: for details).\n       \tautomatically added to the `format-patch` cover letter or\n       \t`request-pull` summary.\n      +\n     -+`branch.<name>.pruneMerged`::\n     ++`branch.<name>.deleteMerged`::\n      +\tIf set to `false`, branch _<name>_ is exempt from\n     -+\t`git branch --prune-merged`.  Useful for a topic branch you\n     ++\t`git branch --delete-merged`.  Useful for a topic branch you\n      +\tintend to develop further after an initial round has been\n      +\tmerged upstream.  Defaults to true.  Explicit deletion via\n      +\t`git branch -d` is unaffected.\n      \n       ## Documentation/git-branch.adoc ##\n     -@@ Documentation/git-branch.adoc: the upstream refs refreshed.\n     +@@ Documentation/git-branch.adoc: A branch is not deleted when:\n       +\n     - A branch is left alone if any of the following holds:\n     - its upstream no longer resolves locally; it is checked out in any\n     --worktree; or its push destination (`<branch>@{push}`) equals its\n     -+worktree; its push destination (`<branch>@{push}`) equals its\n     - upstream (`<branch>@{upstream}`), so it cannot be distinguished\n     --from a freshly pulled trunk that just looks \"fully merged\".\n     -+from a freshly pulled trunk that just looks \"fully merged\"; or\n     -+`branch.<name>.pruneMerged` is set to `false`.\n     + --\n     + * its upstream remote-tracking branch no longer exists,\n     +-* it is checked out in any worktree, or\n     ++* it is checked out in any worktree,\n     + * its push destination (`<branch>@{push}`) equals its upstream\n     +   (`<branch>@{upstream}`), so it cannot be distinguished from a\n     +-  branch that just looks \"fully merged\" right after a pull.\n     ++  branch that just looks \"fully merged\" right after a pull, or\n     ++* `branch.<name>.deleteMerged` is set to `false`.\n     + --\n       +\n     - Branches refused by the \"fully merged\" safety check are listed as\n     - warnings and skipped; pass them to `git branch -D` explicitly if\n     + A branch whose work has not yet been merged into its upstream is\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n     +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tconst char *short_name;\n       \t\tstruct branch *branch;\n       \t\tconst char *upstream, *push;\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      +\t\tint opt_out;\n       \n       \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     - \t\t\tcontinue;\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n     + \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n     +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tif (!push || !strcmp(push, upstream))\n       \t\t\tcontinue;\n       \n     -+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n     ++\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n      +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n      +\t\t    !opt_out) {\n      +\t\t\tif (!quiet)\n      +\t\t\t\tfprintf(stderr,\n     -+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n     ++\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n      +\t\t\tstrbuf_release(&key);\n      +\t\t\tcontinue;\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n       \n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '\n       \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n       '\n       \n     -+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n     ++test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n      +\ttest_when_finished \"rm -rf pm-optout\" &&\n      +\tgit clone pm-upstream pm-optout &&\n      +\tgit -C pm-optout remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch>\n      +\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n      +\tgit -C pm-optout branch two two-commit &&\n      +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n     -+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n     ++\ttest_config -C pm-optout branch.one.deleteMerged false &&\n      +\n     -+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n     ++\tgit -C pm-optout branch --delete-merged \"origin/*\" 2>err &&\n      +\n      +\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n      +\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n      +\ttest_grep \"Skipping .one.\" err\n      +'\n      +\n     -+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n     ++test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n      +\ttest_when_finished \"rm -rf pm-optout-d\" &&\n      +\tgit clone pm-upstream pm-optout-d &&\n      +\tgit -C pm-optout-d branch one one-commit &&\n      +\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n     -+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n     ++\ttest_config -C pm-optout-d branch.one.deleteMerged false &&\n      +\n      +\tgit -C pm-optout-d branch -d one &&\n      +\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n 6:  ede8c61729 ! 7:  7b2b01b988 branch: add --dry-run for --prune-merged\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --dry-run for --prune-merged\n     +    branch: add --dry-run for --delete-merged\n      \n     -    With --dry-run, --prune-merged prints the local branches it would\n     +    With --dry-run, --delete-merged prints the local branches it would\n          delete, one \"Would delete branch <name>\" line each, and exits\n          without touching any ref. The same filtering applies, so the output\n          is exactly the set that the real run would delete.\n      \n     -    --dry-run is only meaningful together with --prune-merged and is\n     +    --dry-run is only meaningful together with --delete-merged and is\n          rejected otherwise.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     --git branch --prune-merged <branch>...\n     -+git branch [--dry-run] --prune-merged <branch>...\n     +-git branch --delete-merged <branch>...\n     ++git branch [--dry-run] --delete-merged <branch>...\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety check are listed as\n     - warnings and skipped; pass them to `git branch -D` explicitly if\n     - you want them gone.\n     +@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is\n     + silently skipped. Delete it with `git branch -D` if you want to\n     + remove it anyway.\n       \n      +`--dry-run`::\n     -+\tWith `--prune-merged`, print which branches would be\n     ++\tWith `--delete-merged`, print which branches would be\n      +\tdeleted and exit without touching any ref.  Useful for\n      +\tsanity-checking a wide pattern like `'origin/*'` before\n      +\tcommitting to the deletion.\n     @@ builtin/branch.c\n      @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n       }\n       \n     - static int prune_merged_branches(int argc, const char **argv,\n     + static int delete_merged_branches(int argc, const char **argv,\n      -\t\t\t\t int quiet)\n      +\t\t\t\t int quiet, int dry_run)\n       {\n       \tstruct ref_store *refs = get_main_ref_store(the_repository);\n       \tstruct ref_filter filter = REF_FILTER_INIT;\n     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n     +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\t\t\t      FILTER_REFS_BRANCHES,\n     - \t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n     + \t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n       \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n      -\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n      +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tint prune_merged = 0;\n     + \tint delete_merged = 0;\n      +\tint dry_run = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t N_(\"edit the description for the branch\")),\n     - \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n     - \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n     + \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n     + \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n     -+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n     ++\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     -+\tif (dry_run && !prune_merged)\n     -+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n     ++\tif (dry_run && !delete_merged)\n     ++\t\tdie(_(\"--dry-run requires --delete-merged\"));\n      +\n       \tif (recurse_submodules_explicit) {\n       \t\tif (!submodule_propagate_branches)\n     @@ builtin/branch.c: int cmd_branch(int argc,\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n     - \t} else if (prune_merged) {\n     --\t\tret = prune_merged_branches(argc, argv, quiet);\n     -+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n     + \t} else if (delete_merged) {\n     +-\t\tret = delete_merged_branches(argc, argv, quiet);\n     ++\t\tret = delete_merged_branches(argc, argv, quiet, dry_run);\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n     +@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n       \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n       '\n       \n     -+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n     ++test_expect_success '--delete-merged --dry-run lists but does not delete' '\n      +\ttest_when_finished \"rm -rf pm-dry\" &&\n      +\tgit clone pm-upstream pm-dry &&\n      +\tgit -C pm-dry remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n      +\tgit -C pm-dry branch two two-commit &&\n      +\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n      +\n     -+\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n     ++\tgit -C pm-dry branch --dry-run --delete-merged \"origin/*\" >actual &&\n      +\ttest_grep \"Would delete branch one \" actual &&\n      +\ttest_grep \"Would delete branch two \" actual &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n      +\tgit -C pm-dry rev-parse --verify refs/heads/two\n      +'\n      +\n     -+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n     ++test_expect_success '--delete-merged --dry-run only lists branches the live run would delete' '\n      +\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n      +\tgit clone pm-upstream pm-dry-mixed &&\n      +\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n     @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n      +\tgit -C pm-dry-mixed branch merged one-commit &&\n      +\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n      +\n     -+\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n     ++\tgit -C pm-dry-mixed branch --dry-run --delete-merged \"origin/*\" >out &&\n      +\ttest_grep \"Would delete branch merged\" out &&\n      +\ttest_grep ! \"Would delete branch wip\" out &&\n      +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n      +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n      +'\n      +\n     -+test_expect_success '--dry-run without --prune-merged is rejected' '\n     ++test_expect_success '--dry-run without --delete-merged is rejected' '\n      +\ttest_must_fail git -C forked branch --dry-run 2>err &&\n     -+\ttest_grep \"requires --prune-merged\" err\n     ++\ttest_grep \"requires --delete-merged\" err\n      +'\n      +\n       test_done\n\n-- \ngitgitgadget\n"},{"id":"545593","messageId":"da741b5ea79cd765997fb2d21edcde39d0e85532.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:16Z","receivedAt":"2026-06-15T16:47:26Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 +++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 113 ++++++++++++++++++++++++++++++++++\n 5 files changed, 220 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..1ddd5a3f6d 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..fac2ad55ac 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,117 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\ttest_commit -C forked-upstream base &&\n+\tgit -C forked-upstream branch one base &&\n+\tgit -C forked-upstream branch two base &&\n+\n+\ttest_create_repo forked-other &&\n+\ttest_commit -C forked-other other-base &&\n+\tgit -C forked-other branch foreign other-base &&\n+\n+\tgit clone forked-upstream forked &&\n+\tgit -C forked remote add -f other ../forked-other &&\n+\tgit -C forked remote set-head origin one &&\n+\tgit -C forked branch local-base &&\n+\tgit -C forked branch --track local-one origin/one &&\n+\tgit -C forked branch --track local-two origin/two &&\n+\tgit -C forked branch --track local-foreign other/foreign &&\n+\tgit -C forked branch --track local-onbase local-base &&\n+\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked --no-tag local-one-work local-one.t &&\n+\tgit -C forked checkout local-foreign &&\n+\ttest_commit -C forked --no-tag local-foreign-work local-foreign.t &&\n+\tgit -C forked checkout --detach\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout --detach\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545594","messageId":"91c35f10cc60b51e3df0f9a4e0ba4997e35bf0c9.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:17Z","receivedAt":"2026-06-15T16:47:28Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() and check_branch_commit() take a pair of int\nbooleans (force and quiet) that the next commits would grow further.\nReplace them with a single \"unsigned int flags\" argument and an\nenum, splitting the bits back into named bool locals so the body\nkeeps reading the same named values.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 36 ++++++++++++++++++++++++------------\n 1 file changed, 24 insertions(+), 12 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..a9be980aef 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tbool force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tbool force;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n+\tforce = flags & DELETE_BRANCH_FORCE;\n+\n \tif (!force)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +882,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"545595","messageId":"e101dd2886ad0142480bc134911674d08812e42d.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:18Z","receivedAt":"2026-06-15T16:47:32Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 17 ++++++++++++-----\n 1 file changed, 12 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a9be980aef..4c569d056a 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       int kinds, unsigned int flags)\n {\n \tbool force = flags & DELETE_BRANCH_FORCE;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!skip_unmerged) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint remote_branch = 0;\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!skip_unmerged)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"545596","messageId":"6c3534901ae4946de85b719be8e9fd9bf16b3dd0.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:19Z","receivedAt":"2026-06-15T16:47:34Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--delete-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 26 ++++++++++++++++++++------\n 1 file changed, 20 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 4c569d056a..1d3f28e4cb 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,8 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -241,6 +246,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n+\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -268,7 +275,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tforce = flags & DELETE_BRANCH_FORCE;\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -339,13 +346,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n-- \ngitgitgadget\n\n"},{"id":"545597","messageId":"5899013b8ff40aaa84183914df9b64959700c12c.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:20Z","receivedAt":"2026-06-15T16:47:36Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --delete-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream. The work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nThree kinds of branches are not deleted:\n\n  * any branch checked out in any worktree\n  * any branch whose upstream remote-tracking branch no longer\n    exists, since a missing upstream is not by itself a sign of\n    integration\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} is the same as <branch>@{upstream}), such as\n    a local \"main\" that tracks and pushes to \"origin/main\". Right\n    after a pull it just looks \"fully merged\", so it is kept. Only\n    branches that push somewhere other than their upstream,\n    typically topics in a fork workflow, are candidates.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  24 ++++\n builtin/branch.c              |  66 ++++++++++-\n t/t3200-branch.sh             | 200 ++++++++++++++++++++++++++++++++++\n 3 files changed, 288 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..f82cfa36d0 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--delete-merged origin/main 'feature*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its upstream remote-tracking branch no longer exists,\n+* it is checked out in any worktree, or\n+* its push destination (`<branch>@{push}`) equals its upstream\n+  (`<branch>@{upstream}`), so it cannot be distinguished from a\n+  branch that just looks \"fully merged\" right after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1d3f28e4cb..f01e03cc26 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n \tNULL\n };\n \n@@ -714,6 +715,60 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+static int delete_merged_branches(int argc, const char **argv,\n+\t\t\t\t int quiet)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch_get_push(branch, NULL);\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\n+\tstrvec_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -755,6 +810,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint delete_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -808,6 +864,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -855,7 +913,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -865,7 +924,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -907,6 +966,9 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged) {\n+\t\tret = delete_merged_branches(argc, argv, quiet);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex fac2ad55ac..b74e119d3b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1830,4 +1830,204 @@ test_expect_success '--forked narrows a <pattern> argument' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\ttest_create_repo pm-upstream &&\n+\ttest_commit -C pm-upstream base &&\n+\tgit -C pm-upstream checkout -b next &&\n+\ttest_commit -C pm-upstream one-commit &&\n+\ttest_commit -C pm-upstream two-commit &&\n+\tgit -C pm-upstream branch one HEAD~ &&\n+\tgit -C pm-upstream branch two HEAD &&\n+\tgit -C pm-upstream branch wip main &&\n+\tgit -C pm-upstream checkout main &&\n+\ttest_create_repo pm-fork\n+'\n+\n+test_expect_success '--delete-merged deletes branches integrated into upstream' '\n+\ttest_when_finished \"rm -rf pm-merged\" &&\n+\tgit clone pm-upstream pm-merged &&\n+\tgit -C pm-merged remote add fork ../pm-fork &&\n+\ttest_config -C pm-merged remote.pushDefault fork &&\n+\ttest_config -C pm-merged push.default current &&\n+\tgit -C pm-merged branch one one-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-merged branch two two-commit &&\n+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-merged branch --delete-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--delete-merged accepts a literal upstream' '\n+\ttest_when_finished \"rm -rf pm-literal\" &&\n+\tgit clone pm-upstream pm-literal &&\n+\tgit -C pm-literal remote add fork ../pm-fork &&\n+\ttest_config -C pm-literal remote.pushDefault fork &&\n+\ttest_config -C pm-literal push.default current &&\n+\tgit -C pm-literal branch one one-commit &&\n+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-literal branch --delete-merged origin/next &&\n+\n+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--delete-merged unions multiple <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-union\" &&\n+\tgit clone pm-upstream pm-union &&\n+\tgit -C pm-union remote add fork ../pm-fork &&\n+\ttest_config -C pm-union remote.pushDefault fork &&\n+\ttest_config -C pm-union push.default current &&\n+\tgit -C pm-union branch one one-commit &&\n+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-union branch two base &&\n+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-union checkout --detach &&\n+\n+\tgit -C pm-union branch --delete-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--delete-merged accepts a local upstream' '\n+\ttest_when_finished \"rm -rf pm-local\" &&\n+\tgit clone pm-upstream pm-local &&\n+\tgit -C pm-local remote add fork ../pm-fork &&\n+\ttest_config -C pm-local remote.pushDefault fork &&\n+\ttest_config -C pm-local push.default current &&\n+\tgit -C pm-local checkout -b mainline &&\n+\tgit -C pm-local branch one one-commit &&\n+\tgit -C pm-local branch --set-upstream-to=mainline one &&\n+\tgit -C pm-local merge --ff-only one-commit &&\n+\n+\tgit -C pm-local branch --delete-merged mainline &&\n+\n+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--delete-merged silently skips un-integrated commits' '\n+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n+\tgit clone pm-upstream pm-unmerged &&\n+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n+\ttest_config -C pm-unmerged push.default current &&\n+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-unmerged local-only &&\n+\tgit -C pm-unmerged checkout - &&\n+\n+\tgit -C pm-unmerged branch --delete-merged \"origin/*\" 2>err &&\n+\ttest_grep ! \"not fully merged\" err &&\n+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n+'\n+\n+test_expect_success '--delete-merged is silent about not-merged-to-HEAD' '\n+\ttest_when_finished \"rm -rf pm-nohead\" &&\n+\tgit clone pm-upstream pm-nohead &&\n+\tgit -C pm-nohead remote add fork ../pm-fork &&\n+\ttest_config -C pm-nohead remote.pushDefault fork &&\n+\ttest_config -C pm-nohead push.default current &&\n+\tgit -C pm-nohead branch topic one-commit &&\n+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n+\n+\tgit -C pm-nohead branch --delete-merged \"origin/*\" 2>err &&\n+\n+\ttest_grep ! \"not yet merged to HEAD\" err &&\n+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--delete-merged skips branches whose upstream is gone' '\n+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n+\tgit clone pm-upstream pm-upstream-gone &&\n+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n+\ttest_config -C pm-upstream-gone push.default current &&\n+\tgit -C pm-upstream-gone branch one one-commit &&\n+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n+\tgit -C pm-upstream-gone branch --delete-merged \"origin/*\" &&\n+\n+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--delete-merged never deletes the checked-out branch' '\n+\ttest_when_finished \"rm -rf pm-head\" &&\n+\tgit clone pm-upstream pm-head &&\n+\tgit -C pm-head remote add fork ../pm-fork &&\n+\ttest_config -C pm-head remote.pushDefault fork &&\n+\ttest_config -C pm-head push.default current &&\n+\tgit -C pm-head checkout -b one one-commit &&\n+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n+\n+\tgit -C pm-head branch --delete-merged \"origin/*\" &&\n+\n+\tgit -C pm-head rev-parse --verify refs/heads/one\n+'\n+\n+test_expect_success '--delete-merged spares branches that push back to their upstream' '\n+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n+\tgit clone pm-upstream pm-push-eq &&\n+\tgit -C pm-push-eq checkout --detach &&\n+\n+\tgit -C pm-push-eq branch --delete-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--delete-merged spares a per-branch pushRemote==upstream remote' '\n+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n+\tgit clone pm-upstream pm-push-branch &&\n+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n+\ttest_config -C pm-push-branch push.default current &&\n+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n+\tgit -C pm-push-branch checkout --detach &&\n+\n+\tgit -C pm-push-branch branch --delete-merged \"origin/*\" &&\n+\n+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n+'\n+\n+test_expect_success '--delete-merged prunes when @{push} differs from @{upstream}' '\n+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n+\tgit clone pm-upstream pm-push-diff &&\n+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n+\ttest_config -C pm-push-diff push.default current &&\n+\tgit -C pm-push-diff branch topic one-commit &&\n+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n+\tgit -C pm-push-diff checkout --detach &&\n+\n+\tgit -C pm-push-diff branch --delete-merged \"origin/*\" &&\n+\n+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--delete-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n+test_expect_success '--delete-merged takes positional <branch> arguments' '\n+\ttest_when_finished \"rm -rf pm-positional\" &&\n+\tgit clone pm-upstream pm-positional &&\n+\tgit -C pm-positional remote add fork ../pm-fork &&\n+\ttest_config -C pm-positional remote.pushDefault fork &&\n+\ttest_config -C pm-positional push.default current &&\n+\tgit -C pm-positional branch one one-commit &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-positional branch two base &&\n+\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n+\tgit -C pm-positional checkout --detach &&\n+\n+\tgit -C pm-positional branch --delete-merged origin/next origin/main &&\n+\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545598","messageId":"72aaca0666457a29705de9525ee0210b4fc5805f.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:21Z","receivedAt":"2026-06-15T16:47:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 ++++++++++++++\n t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++\n 4 files changed, 54 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex f82cfa36d0..91700f2e8a 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -215,10 +215,11 @@ A branch is not deleted when:\n +\n --\n * its upstream remote-tracking branch no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * its push destination (`<branch>@{push}`) equals its upstream\n   (`<branch>@{upstream}`), so it cannot be distinguished from a\n-  branch that just looks \"fully merged\" right after a pull.\n+  branch that just looks \"fully merged\" right after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f01e03cc26..0e1e7c2e6f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -739,6 +739,8 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tstruct strbuf key = STRBUF_INIT;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n@@ -753,6 +755,18 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tstrbuf_release(&key);\n+\t\t\tcontinue;\n+\t\t}\n+\t\tstrbuf_release(&key);\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b74e119d3b..5ac3c2bb5d 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2030,4 +2030,34 @@ test_expect_success '--delete-merged takes positional <branch> arguments' '\n \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n '\n \n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\ttest_when_finished \"rm -rf pm-optout\" &&\n+\tgit clone pm-upstream pm-optout &&\n+\tgit -C pm-optout remote add fork ../pm-fork &&\n+\ttest_config -C pm-optout remote.pushDefault fork &&\n+\ttest_config -C pm-optout push.default current &&\n+\tgit -C pm-optout branch one one-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-optout branch two two-commit &&\n+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n+\ttest_config -C pm-optout branch.one.deleteMerged false &&\n+\n+\tgit -C pm-optout branch --delete-merged \"origin/*\" 2>err &&\n+\n+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n+\ttest_grep \"Skipping .one.\" err\n+'\n+\n+test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n+\tgit clone pm-upstream pm-optout-d &&\n+\tgit -C pm-optout-d branch one one-commit &&\n+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n+\ttest_config -C pm-optout-d branch.one.deleteMerged false &&\n+\n+\tgit -C pm-optout-d branch -d one &&\n+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545599","messageId":"7b2b01b9887d630a47be0f9e98c4a75f0b22626f.1781542042.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v15 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-15T16:47:22Z","receivedAt":"2026-06-15T16:47:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --delete-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 13 ++++++++---\n t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++\n 3 files changed, 61 insertions(+), 4 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 91700f2e8a..09063d74f2 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --delete-merged <branch>...\n+git branch [--dry-run] --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,12 @@ A branch whose work has not yet been merged into its upstream is\n silently skipped. Delete it with `git branch -D` if you want to\n remove it anyway.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 0e1e7c2e6f..d18a830249 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -716,7 +716,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n }\n \n static int delete_merged_branches(int argc, const char **argv,\n-\t\t\t\t int quiet)\n+\t\t\t\t int quiet, int dry_run)\n {\n \tstruct ref_store *refs = get_main_ref_store(the_repository);\n \tstruct ref_filter filter = REF_FILTER_INIT;\n@@ -775,7 +775,8 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t      FILTER_REFS_BRANCHES,\n \t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n-\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \n \tstrvec_clear(&deletable);\n \tref_array_clear(&candidates);\n@@ -825,6 +826,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint delete_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -880,6 +882,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -942,6 +946,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -981,7 +988,7 @@ int cmd_branch(int argc,\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (delete_merged) {\n-\t\tret = delete_merged_branches(argc, argv, quiet);\n+\t\tret = delete_merged_branches(argc, argv, quiet, dry_run);\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 5ac3c2bb5d..1cb32497b8 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2060,4 +2060,48 @@ test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n '\n \n+test_expect_success '--delete-merged --dry-run lists but does not delete' '\n+\ttest_when_finished \"rm -rf pm-dry\" &&\n+\tgit clone pm-upstream pm-dry &&\n+\tgit -C pm-dry remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry remote.pushDefault fork &&\n+\ttest_config -C pm-dry push.default current &&\n+\tgit -C pm-dry branch one one-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n+\tgit -C pm-dry branch two two-commit &&\n+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n+\n+\tgit -C pm-dry branch --dry-run --delete-merged \"origin/*\" >actual &&\n+\ttest_grep \"Would delete branch one \" actual &&\n+\ttest_grep \"Would delete branch two \" actual &&\n+\n+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n+\tgit -C pm-dry rev-parse --verify refs/heads/two\n+'\n+\n+test_expect_success '--delete-merged --dry-run only lists branches the live run would delete' '\n+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n+\tgit clone pm-upstream pm-dry-mixed &&\n+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n+\ttest_config -C pm-dry-mixed push.default current &&\n+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n+\ttest_commit -C pm-dry-mixed local-only &&\n+\tgit -C pm-dry-mixed checkout - &&\n+\tgit -C pm-dry-mixed branch merged one-commit &&\n+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n+\n+\tgit -C pm-dry-mixed branch --dry-run --delete-merged \"origin/*\" >out &&\n+\ttest_grep \"Would delete branch merged\" out &&\n+\ttest_grep ! \"Would delete branch wip\" out &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n+'\n+\n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"545642","messageId":"7b43a0f1-32a0-40f0-8c82-d2ee78809cc2@gmail.com","threadId":"65574","inReplyTo":"ede8c6172963fb8d15f0ae28f4e11501cf42be6c.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 6/6] branch: add --dry-run for --prune-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-16T09:57:08Z","receivedAt":"2026-06-16T09:57:11Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> With --dry-run, --prune-merged prints the local branches it would\n> delete, one \"Would delete branch <name>\" line each, and exits\n> without touching any ref. The same filtering applies, so the output\n> is exactly the set that the real run would delete.\n\nI can see this being very useful.\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 52a0371292..7c52a88af2 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n>   }\n>   \n>   static int prune_merged_branches(int argc, const char **argv,\n> -\t\t\t\t int quiet)\n> +\t\t\t\t int quiet, int dry_run)\n\nLet's not start adding multiple boolean augments - use a flags argument \nlike we do for delete_branches() - if you get feedback on one patch you \nshould think about whether it applies later in the series as well. The \nrest of the implementation looks good.\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 3f7b1fc3d6..305c0141fc 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n>   \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>   '\n>   \n> +test_expect_success '--prune-merged --dry-run lists but does not delete' '\n\nA good way to test --dry-run would be to add it to an existing test \nbefore calling --prune-merged without --dry-run.\n\nThanks\n\nPhillip\n\n> +\ttest_when_finished \"rm -rf pm-dry\" &&\n> +\tgit clone pm-upstream pm-dry &&\n> +\tgit -C pm-dry remote add fork ../pm-fork &&\n> +\ttest_config -C pm-dry remote.pushDefault fork &&\n> +\ttest_config -C pm-dry push.default current &&\n> +\tgit -C pm-dry branch one one-commit &&\n> +\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-dry branch two two-commit &&\n> +\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n> +\n> +\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n> +\ttest_grep \"Would delete branch one \" actual &&\n> +\ttest_grep \"Would delete branch two \" actual &&\n> +\n> +\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n> +\tgit -C pm-dry rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n> +\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n> +\tgit clone pm-upstream pm-dry-mixed &&\n> +\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n> +\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n> +\ttest_config -C pm-dry-mixed push.default current &&\n> +\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n> +\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-dry-mixed local-only &&\n> +\tgit -C pm-dry-mixed checkout - &&\n> +\tgit -C pm-dry-mixed branch merged one-commit &&\n> +\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n> +\n> +\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n> +\ttest_grep \"Would delete branch merged\" out &&\n> +\ttest_grep ! \"Would delete branch wip\" out &&\n> +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n> +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n> +'\n> +\n> +test_expect_success '--dry-run without --prune-merged is rejected' '\n> +\ttest_must_fail git -C forked branch --dry-run 2>err &&\n> +\ttest_grep \"requires --prune-merged\" err\n> +'\n> +\n>   test_done\n\n"},{"id":"545643","messageId":"fe752544-b076-4aa4-a9d6-d48fe15ce57b@gmail.com","threadId":"65574","inReplyTo":"d691d5051b35a569dbd3f4a0488030a7d84d72f9.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-16T09:57:45Z","receivedAt":"2026-06-16T09:57:48Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Setting branch.<name>.pruneMerged=false exempts that branch from\n> \"git branch --prune-merged\", which is useful for a topic you want\n> to keep developing after an early round of it has been merged\n> upstream. Unless --quiet is given, each skip is reported so the\n> user knows why their topic was kept.\n\nSounds good\n\n> @@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,\n>   \t\tif (!push || !strcmp(push, upstream))\n>   \t\t\tcontinue;\n>   \n> +\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n> +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n> +\t\t    !opt_out) {\n> +\t\t\tif (!quiet)\n> +\t\t\t\tfprintf(stderr,\n> +\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n> +\t\t\t\t\tshort_name, short_name);\n> +\t\t\tstrbuf_release(&key);\n> +\t\t\tcontinue;\n> +\t\t}\n> +\t\tstrbuf_release(&key);\n\nAs this is in a loop we don't want to free the buffer on each iteration, \nonly at the end. You should call strbuf_reset() just before \nstrbuf_addf() above and then move this call to strbuf_release() out of \nthe loop.\n\n> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n> +\ttest_when_finished \"rm -rf pm-optout\" &&\n> +\tgit clone pm-upstream pm-optout &&\n> +\tgit -C pm-optout remote add fork ../pm-fork &&\n> +\ttest_config -C pm-optout remote.pushDefault fork &&\n> +\ttest_config -C pm-optout push.default current &&\n> +\tgit -C pm-optout branch one one-commit &&\n> +\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-optout branch two two-commit &&\n> +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n> +\ttest_config -C pm-optout branch.one.pruneMerged false &&\n> +\n> +\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n> +\n> +\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n> +\ttest_grep \"Skipping .one.\" err\n\nDo we really need a whole new setup to test this - can't we just add a \nprotected branch to an existing test?\n\nThanks\n\nPhillip\n\n> +\n> +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n> +\ttest_when_finished \"rm -rf pm-optout-d\" &&\n> +\tgit clone pm-upstream pm-optout-d &&\n> +\tgit -C pm-optout-d branch one one-commit &&\n> +\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n> +\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n> +\n> +\tgit -C pm-optout-d branch -d one &&\n> +\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n> +'\n> +\n>   test_done\n\n"},{"id":"545644","messageId":"78b6dfdd-df61-4c44-96eb-b527cb26243c@gmail.com","threadId":"65574","inReplyTo":"9924373da0a0598cabe4f08f3bc4200833679171.1780999917.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-16T09:59:58Z","receivedAt":"2026-06-16T10:00:01Z","isPatch":true,"body":"Hi Harald\n\nOn 09/06/2026 11:11, Harald Nordgren via GitGitGadget wrote:\n\nCarrying on where I left off yesterday ...\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 4e7deddc04..27ea1319bb 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n>   \ttest_grep \"requires a value\" err\n>   '\n>   \n> +test_expect_success '--prune-merged: setup' '\n> +\ttest_create_repo pm-upstream &&\n\nThe rest of this test would be easier to read if we did\n\n\t(\n\t\tcd pm-upstream &&\n\t\t...\n\t)\n\nrather than prefixing every command with \"-C pm-upstream\"\n\n> +\ttest_commit -C pm-upstream base &&\n> +\tgit -C pm-upstream checkout -b next &&\n> +\ttest_commit -C pm-upstream one-commit &&\n> +\ttest_commit -C pm-upstream two-commit &&\n> +\tgit -C pm-upstream branch one HEAD~ &&\n> +\tgit -C pm-upstream branch two HEAD &&\n> +\tgit -C pm-upstream branch wip main &&\n> +\tgit -C pm-upstream checkout main &&\n> +\ttest_create_repo pm-fork\n> +'\n> +\n> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> +\ttest_when_finished \"rm -rf pm-merged\" &&\n> +\tgit clone pm-upstream pm-merged &&\n> +\tgit -C pm-merged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-merged remote.pushDefault fork &&\n> +\ttest_config -C pm-merged push.default current &&\n\nSo we clone upstream and add fork as the default push remote. I find the \npm- prefixes rather distracting. It would be clearer to me if we just \ncalled the repositories \"upstream\", \"fork\" and \"repo\"\n\n> +\tgit -C pm-merged branch one one-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-merged branch two two-commit &&\n> +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n\nNow we set up a couple of local branches with no local commits that \ntrack origin/next which seems a bit odd. Why don't we create local \nbranches based one origin/next (and origin/main if we're using a \nwildcard below) with some local commits like a user would?\n\n> +\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n\nHere we delete all the branches whose upstream is on origin - which is \nall the branches that we've created so we're not really testing the \nsafety features.\n\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n\nThis verifies that the branches are deleted. It would be a good idea to \ncheck the output of command above to check --prune-merged prints what we \nexpect it to and that it leaves branches with other upstreams.\n\n> +test_expect_success '--prune-merged accepts a literal upstream' '\n> +\ttest_when_finished \"rm -rf pm-literal\" &&\n> +\tgit clone pm-upstream pm-literal &&\n\nlet's not litter the test directory with a hundred repositories - just \ncall it \"repo\" and add remove it with test_when_finished in each test, \nor reuse it so we don't waste time cloning and setting up the config \neach time (that would mean not using test_config).\n\n> +\tgit -C pm-literal remote add fork ../pm-fork &&\n> +\ttest_config -C pm-literal remote.pushDefault fork &&\n> +\ttest_config -C pm-literal push.default current &&\n> +\tgit -C pm-literal branch one one-commit &&\n> +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-literal branch --prune-merged origin/next &&\n> +\n> +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n\nAgain we're not testing that nothing else is deleted.\n\n> +'\n> +\n> +test_expect_success '--prune-merged unions multiple <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-union\" &&\n> +\tgit clone pm-upstream pm-union &&\n> +\tgit -C pm-union remote add fork ../pm-fork &&\n> +\ttest_config -C pm-union remote.pushDefault fork &&\n> +\ttest_config -C pm-union push.default current &&\n> +\tgit -C pm-union branch one one-commit &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-union branch two base &&\n> +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n> +\tgit -C pm-union checkout --detach &&\n> +\n> +\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n\nThis is more interesting - we don't need the test for a single literal \nupstream if we're doing this. Again we need to test the safety features. \nAs these are integration tests you can do that at the same time as \ntesting that some branches are removed - you don't need so many separate \n(expensive) tests.\n\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n> +'\n> +\n> +test_expect_success '--prune-merged accepts a local upstream' '\n> +\ttest_when_finished \"rm -rf pm-local\" &&\n> +\tgit clone pm-upstream pm-local &&\n> +\tgit -C pm-local remote add fork ../pm-fork &&\n> +\ttest_config -C pm-local remote.pushDefault fork &&\n> +\ttest_config -C pm-local push.default current &&\n> +\tgit -C pm-local checkout -b trunk &&\n> +\tgit -C pm-local branch one one-commit &&\n> +\tgit -C pm-local branch --set-upstream-to=trunk one &&\n> +\tgit -C pm-local merge --ff-only one-commit &&\n> +\n> +\tgit -C pm-local branch --prune-merged trunk &&\n\nCan't we test a local upstream at the same time as a remote upstream and \nsave a test case?\n\n> +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n> +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n> +\tgit clone pm-upstream pm-unmerged &&\n> +\tgit -C pm-unmerged remote add fork ../pm-fork &&\n> +\ttest_config -C pm-unmerged remote.pushDefault fork &&\n> +\ttest_config -C pm-unmerged push.default current &&\n> +\tgit -C pm-unmerged checkout -b wip origin/wip &&\n> +\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> +\ttest_commit -C pm-unmerged local-only &&\n> +\tgit -C pm-unmerged checkout - &&\n> +\n> +\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n> +\ttest_grep \"not fully merged\" err &&\n> +\ttest_grep ! \"If you are sure you want to delete it\" err &&\n\nI'm always suspicious of test_grep when we know what the output should \nlook like - it might be better to use test_cmp. This test does not check \nthat we also delete branches that are merged when we see one that isn't.\n\nI'm going to stop here - the tests I've read seem to me to be too much \nlike unit tests checking one aspect of the implementation in isolation \nrather than checking that the whole feature works as expected.\n\nThanks\n\nPhillip\n\n> +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n> +'\n> +\n> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n> +\ttest_when_finished \"rm -rf pm-nohead\" &&\n> +\tgit clone pm-upstream pm-nohead &&\n> +\tgit -C pm-nohead remote add fork ../pm-fork &&\n> +\ttest_config -C pm-nohead remote.pushDefault fork &&\n> +\ttest_config -C pm-nohead push.default current &&\n> +\tgit -C pm-nohead branch topic one-commit &&\n> +\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n> +\n> +\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n> +\n> +\ttest_grep ! \"not yet merged to HEAD\" err &&\n> +\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged skips branches whose upstream is gone' '\n> +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n> +\tgit clone pm-upstream pm-upstream-gone &&\n> +\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n> +\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n> +\ttest_config -C pm-upstream-gone push.default current &&\n> +\tgit -C pm-upstream-gone branch one one-commit &&\n> +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n> +\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged never deletes the checked-out branch' '\n> +\ttest_when_finished \"rm -rf pm-head\" &&\n> +\tgit clone pm-upstream pm-head &&\n> +\tgit -C pm-head remote add fork ../pm-fork &&\n> +\ttest_config -C pm-head remote.pushDefault fork &&\n> +\ttest_config -C pm-head push.default current &&\n> +\tgit -C pm-head checkout -b one one-commit &&\n> +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n> +\n> +\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-head rev-parse --verify refs/heads/one\n> +'\n> +\n> +test_expect_success '--prune-merged spares branches that push back to their upstream' '\n> +\ttest_when_finished \"rm -rf pm-push-eq\" &&\n> +\tgit clone pm-upstream pm-push-eq &&\n> +\tgit -C pm-push-eq checkout --detach &&\n> +\n> +\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n> +\ttest_when_finished \"rm -rf pm-push-branch\" &&\n> +\tgit clone pm-upstream pm-push-branch &&\n> +\tgit -C pm-push-branch remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-branch remote.pushDefault fork &&\n> +\ttest_config -C pm-push-branch push.default current &&\n> +\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n> +\tgit -C pm-push-branch checkout --detach &&\n> +\n> +\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n> +\n> +\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n> +'\n> +\n> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n> +\ttest_when_finished \"rm -rf pm-push-diff\" &&\n> +\tgit clone pm-upstream pm-push-diff &&\n> +\tgit -C pm-push-diff remote add fork ../pm-fork &&\n> +\ttest_config -C pm-push-diff remote.pushDefault fork &&\n> +\ttest_config -C pm-push-diff push.default current &&\n> +\tgit -C pm-push-diff branch topic one-commit &&\n> +\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n> +\tgit -C pm-push-diff checkout --detach &&\n> +\n> +\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n> +\n> +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--prune-merged requires at least one <branch>' '\n> +\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n> +\ttest_grep \"requires at least one <branch>\" err\n> +'\n> +\n> +test_expect_success '--prune-merged takes positional <branch> arguments' '\n> +\ttest_when_finished \"rm -rf pm-positional\" &&\n> +\tgit clone pm-upstream pm-positional &&\n> +\tgit -C pm-positional remote add fork ../pm-fork &&\n> +\ttest_config -C pm-positional remote.pushDefault fork &&\n> +\ttest_config -C pm-positional push.default current &&\n> +\tgit -C pm-positional branch one one-commit &&\n> +\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n> +\tgit -C pm-positional branch two base &&\n> +\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n> +\tgit -C pm-positional checkout --detach &&\n> +\n> +\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n> +\n> +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n> +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n> +'\n> +\n>   test_done\n\n"},{"id":"545677","messageId":"CAHwyqnXjt962HBzcMwRfghiWtv7Wkx6CUxapMv9O9igg27A_fw@mail.gmail.com","threadId":"65574","inReplyTo":"7b43a0f1-32a0-40f0-8c82-d2ee78809cc2@gmail.com","subject":"Re: [PATCH v14 6/6] branch: add --dry-run for --prune-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-16T18:28:05Z","receivedAt":"2026-06-16T18:28:44Z","isPatch":true,"body":"> > With --dry-run, --prune-merged prints the local branches it would\n> > delete, one \"Would delete branch <name>\" line each, and exits\n> > without touching any ref. The same filtering applies, so the output\n> > is exactly the set that the real run would delete.\n>\n> I can see this being very useful.\n\nGreat to hear and thanks for taking the time to review this! Much appreciated!\n\n> >   static int prune_merged_branches(int argc, const char **argv,\n> > -                              int quiet)\n> > +                              int quiet, int dry_run)\n>\n> Let's not start adding multiple boolean augments - use a flags argument\n> like we do for delete_branches() - if you get feedback on one patch you\n> should think about whether it applies later in the series as well. The\n> rest of the implementation looks good.\n\nI'm trying to generalize all feedback, but sometimes I miss things.\nThanks for pointing it out!\n\n\nHarald\n"},{"id":"545678","messageId":"CAHwyqnUsjpCHfS=eBphmkdDGYpQZ_LQUJi1mjrxV8ZXi+w4yhg@mail.gmail.com","threadId":"65574","inReplyTo":"78b6dfdd-df61-4c44-96eb-b527cb26243c@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-16T19:15:36Z","receivedAt":"2026-06-16T19:16:14Z","isPatch":true,"body":"> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> > index 4e7deddc04..27ea1319bb 100755\n> > --- a/t/t3200-branch.sh\n> > +++ b/t/t3200-branch.sh\n> > @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n> >       test_grep \"requires a value\" err\n> >   '\n> >\n> > +test_expect_success '--prune-merged: setup' '\n> > +     test_create_repo pm-upstream &&\n>\n> The rest of this test would be easier to read if we did\n>\n>         (\n>                 cd pm-upstream &&\n>                 ...\n>         )\n>\n> rather than prefixing every command with \"-C pm-upstream\"\n\nI feel like the discussion to nest or not to nest has come up many\ntimes in other topics as well. I don't feel strongly about either way,\nbut I just want to flag that if I change it now, another reviewer\nmight ask me to change it back later.\n\nShould the rules be to nest inside of setup functions (and helpers?)\nbut not inside the actual tests?\n\n> > +     test_commit -C pm-upstream base &&\n> > +     git -C pm-upstream checkout -b next &&\n> > +     test_commit -C pm-upstream one-commit &&\n> > +     test_commit -C pm-upstream two-commit &&\n> > +     git -C pm-upstream branch one HEAD~ &&\n> > +     git -C pm-upstream branch two HEAD &&\n> > +     git -C pm-upstream branch wip main &&\n> > +     git -C pm-upstream checkout main &&\n> > +     test_create_repo pm-fork\n> > +'\n> > +\n> > +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n> > +     test_when_finished \"rm -rf pm-merged\" &&\n> > +     git clone pm-upstream pm-merged &&\n> > +     git -C pm-merged remote add fork ../pm-fork &&\n> > +     test_config -C pm-merged remote.pushDefault fork &&\n> > +     test_config -C pm-merged push.default current &&\n>\n> So we clone upstream and add fork as the default push remote. I find the\n> pm- prefixes rather distracting. It would be clearer to me if we just\n> called the repositories \"upstream\", \"fork\" and \"repo\"\n\nGood point.\n\n> > +     test_must_fail git -C pm-local rev-parse --verify refs/heads/one\n> > +'\n> > +\n> > +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n> > +     test_when_finished \"rm -rf pm-unmerged\" &&\n> > +     git clone pm-upstream pm-unmerged &&\n> > +     git -C pm-unmerged remote add fork ../pm-fork &&\n> > +     test_config -C pm-unmerged remote.pushDefault fork &&\n> > +     test_config -C pm-unmerged push.default current &&\n> > +     git -C pm-unmerged checkout -b wip origin/wip &&\n> > +     git -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n> > +     test_commit -C pm-unmerged local-only &&\n> > +     git -C pm-unmerged checkout - &&\n> > +\n> > +     git -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n> > +     test_grep \"not fully merged\" err &&\n> > +     test_grep ! \"If you are sure you want to delete it\" err &&\n>\n> I'm always suspicious of test_grep when we know what the output should\n> look like - it might be better to use test_cmp. This test does not check\n> that we also delete branches that are merged when we see one that isn't.\n>\n> I'm going to stop here - the tests I've read seem to me to be too much\n> like unit tests checking one aspect of the implementation in isolation\n> rather than checking that the whole feature works as expected.\n\nI'll respond to the rest here: Excellent points regarding the testing\naboce, I will take a look at doing this.\n\n\nHarald\n"},{"id":"545744","messageId":"f68e2a11-02a5-47b9-a01a-458eba821c37@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-17T10:01:22Z","receivedAt":"2026-06-17T10:01:25Z","isPatch":true,"body":"Hi Harald\n\nOur SubmittingPatches documentation recommends waiting for the \ndiscussion to settle before sending a new version. When you know someone \nis going send more comments on a series it is a good idea to wait for \nthem before sending a new version to avoid too much churn on the list \nwhich makes it hard for people to keep up. I'm not going to read this \nversion in detail because I know another version will be needed but I \ndid spot a couple of things in the summary below.\n\nOn 15/06/2026 17:47, Harald Nordgren via GitGitGadget wrote:\n>   * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n>     final, but something to advance the discussion.\n>   * --delete-merged now silently skips not-yet-merged branches instead of\n>     warning.\n\nGood\n\n>   * --forked now accepts a bare remote name (e.g. origin) for the branch\n>     origin/HEAD points at using DWIM.\n\nThe range-diff below does not show any changes to the implementation, \nonly the Documentation and tests\n\n>   * Initialized the delete_branches() flag locals where declared. Only force\n>     stays deferred.\n\nNot changing force sounds like a bad idea. The whole point of unpacking \nthe flags at the start of the function is to avoid accidental \nregressions. Unpacking the flags into separate variables means the rest \nof the function does not need to know that the function arguments have \nchanged.\n\nThanks\n\nPhillip\n\n>   * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n>     branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n>     unreachable non-branch ref, and reworked --delete-merged doc wording.\n>   * Broadened the --forked tests (local commits for realism, remote add -f,\n>     --forked <pattern> <branch> coverage), renamed the misleading trunk\n>     fixture, and replaced the misnamed detached branch with git checkout\n>     --detach.\n> \n> Harald Nordgren (7):\n>    branch: add --forked filter for --list mode\n>    branch: convert delete_branches() to a flags argument\n>    branch: let delete_branches skip unmerged branches on bulk refusal\n>    branch: prepare delete_branches for a bulk caller\n>    branch: add --delete-merged <branch>\n>    branch: add branch.<name>.deleteMerged opt-out\n>    branch: add --dry-run for --delete-merged\n> \n>   Documentation/config/branch.adoc |   7 +\n>   Documentation/git-branch.adoc    |  43 +++-\n>   builtin/branch.c                 | 184 ++++++++++++---\n>   ref-filter.c                     |  70 ++++++\n>   ref-filter.h                     |  10 +\n>   t/t3200-branch.sh                | 387 +++++++++++++++++++++++++++++++\n>   6 files changed, 673 insertions(+), 28 deletions(-)\n> \n> \n> base-commit: ea97ad8d017de0c9037451a78008a0fd60abea0c\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v15\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v15\n> Pull-Request: https://github.com/git/git/pull/2285\n> \n> Range-diff vs v14:\n> \n>   1:  7383872f4b ! 1:  da741b5ea7 branch: add --forked filter for --list mode\n>       @@ Commit message\n>        \n>            Add a --forked option to \"git branch\" list mode that lists only\n>            branches whose configured upstream matches <branch>. The argument\n>       -    can be a ref (e.g. \"origin/main\", \"master\") or a shell glob\n>       +    can be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n>       +    \"origin\" for the branch its origin/HEAD points at, or a shell glob\n>            (e.g. \"origin/*\"), and may be repeated to widen the filter.\n>        \n>            It is an ordinary list filter, so it combines with the others:\n>       @@ Commit message\n>            lists branches forked from origin that are already merged into\n>            origin/main, and --no-merged inverts the question.\n>        \n>       -    This is the building block for --prune-merged, which deletes the\n>       +    This is the building block for --delete-merged, which deletes the\n>            listed branches once they have landed on their upstream.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>       @@ Documentation/git-branch.adoc: superproject's \"origin/main\", but tracks the subm\n>        +`--forked <branch>`::\n>        +\tOnly list branches whose configured upstream matches\n>        +\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n>       -+\t`master`) or a shell-style glob (e.g. `'origin/*'`). The\n>       -+\toption can be repeated to widen the filter. Implies `--list`.\n>       ++\t`master`), a remote name like `origin` for the branch its\n>       ++\t`origin/HEAD` points at, or a shell-style glob (e.g.\n>       ++\t`'origin/*'`). The option can be repeated to widen the\n>       ++\tfilter. Implies `--list`.\n>        +\n>         `--points-at <object>`::\n>         \tOnly list branches of _<object>_.\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\tgit -C forked-other branch foreign other-base &&\n>        +\n>        +\tgit clone forked-upstream forked &&\n>       -+\tgit -C forked remote add other ../forked-other &&\n>       -+\tgit -C forked fetch other &&\n>       ++\tgit -C forked remote add -f other ../forked-other &&\n>       ++\tgit -C forked remote set-head origin one &&\n>        +\tgit -C forked branch local-base &&\n>        +\tgit -C forked branch --track local-one origin/one &&\n>        +\tgit -C forked branch --track local-two origin/two &&\n>        +\tgit -C forked branch --track local-foreign other/foreign &&\n>       -+\tgit -C forked branch detached &&\n>       -+\tgit -C forked branch --track local-trunk local-base\n>       ++\tgit -C forked branch --track local-onbase local-base &&\n>       ++\n>       ++\tgit -C forked checkout local-one &&\n>       ++\ttest_commit -C forked --no-tag local-one-work local-one.t &&\n>       ++\tgit -C forked checkout local-foreign &&\n>       ++\ttest_commit -C forked --no-tag local-foreign-work local-foreign.t &&\n>       ++\tgit -C forked checkout --detach\n>        +'\n>        +\n>        +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\n>        +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n>        +\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n>       -+\techo local-trunk >expect &&\n>       ++\techo local-onbase >expect &&\n>        +\ttest_cmp expect actual\n>        +'\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n>        +\tcat >expect <<-\\EOF &&\n>        +\tlocal-foreign\n>       -+\tlocal-trunk\n>       ++\tlocal-onbase\n>        +\tEOF\n>        +\ttest_cmp expect actual\n>        +'\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +'\n>        +\n>        +test_expect_success '--forked composes with --no-merged' '\n>       -+\ttest_when_finished \"git -C forked checkout detached\" &&\n>       ++\ttest_when_finished \"git -C forked checkout --detach\" &&\n>        +\tgit -C forked checkout local-one &&\n>        +\ttest_commit -C forked local-only &&\n>        +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +\ttest_must_fail git -C forked branch --forked 2>err &&\n>        +\ttest_grep \"requires a value\" err\n>        +'\n>       ++\n>       ++test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n>       ++\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n>       ++\techo local-one >expect &&\n>       ++\ttest_cmp expect actual\n>       ++'\n>       ++\n>       ++test_expect_success '--forked narrows a <pattern> argument' '\n>       ++\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n>       ++\t\t--format=\"%(refname:short)\" >actual &&\n>       ++\tcat >expect <<-\\EOF &&\n>       ++\tlocal-one\n>       ++\tlocal-two\n>       ++\tEOF\n>       ++\ttest_cmp expect actual\n>       ++'\n>        +\n>         test_done\n>   2:  7ef9502e01 ! 2:  91c35f10cc branch: let delete_branches warn instead of error on bulk refusal\n>       @@ Metadata\n>        Author: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Commit message ##\n>       -    branch: let delete_branches warn instead of error on bulk refusal\n>       +    branch: convert delete_branches() to a flags argument\n>        \n>       -    Add a warn-only mode to delete_branches() and check_branch_commit()\n>       -    so a bulk caller can report branches that are not fully merged as a\n>       -    short warning and carry on, rather than erroring with the longer\n>       -    \"use 'git branch -D'\" advice that the plain \"git branch -d\" path\n>       -    emits. Existing callers are unaffected.\n>       +    delete_branches() and check_branch_commit() take a pair of int\n>       +    booleans (force and quiet) that the next commits would grow further.\n>       +    Replace them with a single \"unsigned int flags\" argument and an\n>       +    enum, splitting the bits back into named bool locals so the body\n>       +    keeps reading the same named values.\n>       +\n>       +    No change in behavior.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n>        +enum delete_branch_flags {\n>        +\tDELETE_BRANCH_FORCE = (1 << 0),\n>        +\tDELETE_BRANCH_QUIET = (1 << 1),\n>       -+\tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n>        +};\n>        +\n>         static int check_branch_commit(const char *branchname, const char *refname,\n>       @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n>        -\t\t\t       int kinds, int force)\n>        +\t\t\t       int kinds, unsigned int flags)\n>         {\n>       -+\tint force = flags & DELETE_BRANCH_FORCE;\n>       ++\tbool force = flags & DELETE_BRANCH_FORCE;\n>         \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n>         \tif (!force && !rev) {\n>         \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n>       - \t\treturn -1;\n>       - \t}\n>       - \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n>       --\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n>       --\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n>       --\t\t\t\t  _(\"If you are sure you want to delete it, \"\n>       --\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n>       -+\t\tif (flags & DELETE_BRANCH_WARN_ONLY) {\n>       -+\t\t\twarning(_(\"the branch '%s' is not fully merged\"),\n>       -+\t\t\t\tbranchname);\n>       -+\t\t} else {\n>       -+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n>       -+\t\t\t      branchname);\n>       -+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n>       -+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n>       -+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n>       -+\t\t}\n>       - \t\treturn -1;\n>       - \t}\n>       - \treturn 0;\n>        @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n>         \tstrbuf_release(&buf);\n>         }\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n>         \tint i;\n>         \tint ret = 0;\n>         \tint remote_branch = 0;\n>       -+\tint force, quiet;\n>       ++\tbool force;\n>       ++\tbool quiet = flags & DELETE_BRANCH_QUIET;\n>         \tstruct strbuf bname = STRBUF_INIT;\n>         \tenum interpret_branch_kind allowed_interpret;\n>         \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n>         \tbranch_name_pos = strcspn(fmt, \"%\");\n>         \n>        +\tforce = flags & DELETE_BRANCH_FORCE;\n>       -+\tquiet = flags & DELETE_BRANCH_QUIET;\n>        +\n>         \tif (!force)\n>         \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n>        +\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>         \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n>        -\t\t\t\t\tforce)) {\n>       --\t\t\tret = 1;\n>        +\t\t\t\t\tflags)) {\n>       -+\t\t\tif (!(flags & DELETE_BRANCH_WARN_ONLY))\n>       -+\t\t\t\tret = 1;\n>       + \t\t\tret = 1;\n>         \t\t\tgoto next;\n>         \t\t}\n>         \n>   -:  ---------- > 3:  e101dd2886 branch: let delete_branches skip unmerged branches on bulk refusal\n>   3:  259113e304 ! 4:  6c3534901a branch: prepare delete_branches for a bulk caller\n>       @@ Commit message\n>            branch: prepare delete_branches for a bulk caller\n>        \n>            Teach delete_branches() two new modes for the upcoming\n>       -    --prune-merged: one that asks only whether a branch is merged into\n>       +    --delete-merged: one that asks only whether a branch is merged into\n>            its upstream, without falling back to HEAD when there is no\n>            upstream, and one that rehearses the deletions without removing any\n>            ref. Existing callers keep their current behavior.\n>       @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n>        @@ builtin/branch.c: enum delete_branch_flags {\n>         \tDELETE_BRANCH_FORCE = (1 << 0),\n>         \tDELETE_BRANCH_QUIET = (1 << 1),\n>       - \tDELETE_BRANCH_WARN_ONLY = (1 << 2),\n>       + \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n>        +\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n>        +\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n>         };\n>         \n>         static int check_branch_commit(const char *branchname, const char *refname,\n>        @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n>       - \tint i;\n>       - \tint ret = 0;\n>       - \tint remote_branch = 0;\n>       --\tint force, quiet;\n>       -+\tint force, quiet, dry_run, no_head_fallback;\n>       + \tbool force;\n>       + \tbool quiet = flags & DELETE_BRANCH_QUIET;\n>       + \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n>       ++\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n>       ++\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n>         \tstruct strbuf bname = STRBUF_INIT;\n>         \tenum interpret_branch_kind allowed_interpret;\n>         \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n>        @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n>         \n>         \tforce = flags & DELETE_BRANCH_FORCE;\n>       - \tquiet = flags & DELETE_BRANCH_QUIET;\n>       -+\tdry_run = flags & DELETE_BRANCH_DRY_RUN;\n>       -+\tno_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n>         \n>        -\tif (!force)\n>        +\tif (!force && !no_head_fallback)\n>   4:  9924373da0 ! 5:  5899013b8f branch: add --prune-merged <branch>\n>       @@ Metadata\n>        Author: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Commit message ##\n>       -    branch: add --prune-merged <branch>\n>       +    branch: add --delete-merged <branch>\n>        \n>       -            git branch --prune-merged <branch>...\n>       +            git branch --delete-merged <branch>...\n>        \n>            deletes the local branches that \"--forked <branch>\" would list,\n>            keeping only those whose tip is reachable from their configured\n>       -    upstream: the work has already landed on the upstream they track,\n>       +    upstream. The work has already landed on the upstream they track,\n>            so the local copy is no longer needed.\n>        \n>       -    Reachability is read from local refs; nothing is fetched. Run\n>       -    \"git fetch\" first if you want fresh upstream refs.\n>       +    Three kinds of branches are not deleted:\n>        \n>       -    Three kinds of branches are spared:\n>       -\n>       -      * any branch checked out in any worktree;\n>       -      * any branch whose upstream no longer resolves locally, since a\n>       -        missing upstream is not by itself a sign of integration;\n>       +      * any branch checked out in any worktree\n>       +      * any branch whose upstream remote-tracking branch no longer\n>       +        exists, since a missing upstream is not by itself a sign of\n>       +        integration\n>              * any branch whose push destination equals its upstream\n>                (<branch>@{push} is the same as <branch>@{upstream}), such as\n>                a local \"main\" that tracks and pushes to \"origin/main\". Right\n>       -        after a pull it just looks \"fully merged\", so it is left\n>       -        alone. Only branches that push somewhere other than their\n>       -        upstream, typically topics in a fork workflow, are candidates.\n>       +        after a pull it just looks \"fully merged\", so it is kept. Only\n>       +        branches that push somewhere other than their upstream,\n>       +        typically topics in a fork workflow, are candidates.\n>        \n>       -    Branches that are not yet merged into their upstream are reported\n>       -    as a short warning and skipped, so one unmerged topic does not\n>       -    abort the whole sweep.\n>       +    A branch whose work is not yet merged into its upstream is silently\n>       +    skipped, so one unmerged topic does not abort the whole sweep.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n>         git branch (-c|-C) [<old-branch>] <new-branch>\n>         git branch (-d|-D) [-r] <branch-name>...\n>         git branch --edit-description [<branch-name>]\n>       -+git branch --prune-merged <branch>...\n>       ++git branch --delete-merged <branch>...\n>         \n>         DESCRIPTION\n>         -----------\n>       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n>         \tPrint the name of the current branch. In detached `HEAD` state,\n>         \tnothing is printed.\n>         \n>       -+`--prune-merged <branch>...`::\n>       ++`--delete-merged <branch>...`::\n>        +\tDelete the local branches that `--forked` would list for the\n>        +\tgiven _<branch>_ arguments, but only those whose tip is\n>        +\treachable from their configured upstream. In other words, the\n>        +\twork on the branch has already landed on the upstream it\n>        +\ttracks, so the local copy is no longer needed. Several\n>        +\t_<branch>_ patterns may be given, e.g. `git branch\n>       -+\t--prune-merged origin/main 'feature*'`.\n>       ++\t--delete-merged origin/main 'feature*'`.\n>        ++\n>       -+Reachability is checked against whatever the upstream refs say\n>       -+locally; nothing is fetched. Run `git fetch` first if you want\n>       -+the upstream refs refreshed.\n>       ++A branch is not deleted when:\n>        ++\n>       -+A branch is left alone if any of the following holds:\n>       -+its upstream no longer resolves locally; it is checked out in any\n>       -+worktree; or its push destination (`<branch>@{push}`) equals its\n>       -+upstream (`<branch>@{upstream}`), so it cannot be distinguished\n>       -+from a freshly pulled trunk that just looks \"fully merged\".\n>       ++--\n>       ++* its upstream remote-tracking branch no longer exists,\n>       ++* it is checked out in any worktree, or\n>       ++* its push destination (`<branch>@{push}`) equals its upstream\n>       ++  (`<branch>@{upstream}`), so it cannot be distinguished from a\n>       ++  branch that just looks \"fully merged\" right after a pull.\n>       ++--\n>        ++\n>       -+Branches refused by the \"fully merged\" safety check are listed as\n>       -+warnings and skipped; pass them to `git branch -D` explicitly if\n>       -+you want them gone.\n>       ++A branch whose work has not yet been merged into its upstream is\n>       ++silently skipped. Delete it with `git branch -D` if you want to\n>       ++remove it anyway.\n>        +\n>         `-v`::\n>         `-vv`::\n>       @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n>         \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>         \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>         \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n>       -+\tN_(\"git branch [<options>] --prune-merged <branch>...\"),\n>       ++\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n>         \tNULL\n>         };\n>         \n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>         \treturn 0;\n>         }\n>         \n>       -+static int prune_merged_branches(int argc, const char **argv,\n>       ++static int delete_merged_branches(int argc, const char **argv,\n>        +\t\t\t\t int quiet)\n>        +{\n>        +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n>        +\tstruct ref_filter filter = REF_FILTER_INIT;\n>       -+\tstruct ref_array candidates;\n>       ++\tstruct ref_array candidates = { 0 };\n>        +\tstruct strvec deletable = STRVEC_INIT;\n>        +\tint i, ret = 0;\n>        +\n>        +\tif (!argc)\n>       -+\t\tdie(_(\"--prune-merged requires at least one <branch>\"));\n>       ++\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n>        +\n>        +\tfor (i = 0; i < argc; i++)\n>        +\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n>        +\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n>        +\n>        +\tfilter.kind = FILTER_REFS_BRANCHES;\n>       -+\tmemset(&candidates, 0, sizeof(candidates));\n>        +\tfilter_refs(&candidates, &filter, filter.kind);\n>        +\n>        +\tfor (i = 0; i < candidates.nr; i++) {\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\t\tconst char *upstream, *push;\n>        +\n>        +\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n>       -+\t\t\tcontinue;\n>       ++\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n>        +\t\tif (branch_checked_out(full_name))\n>        +\t\t\tcontinue;\n>        +\n>        +\t\tbranch = branch_get(short_name);\n>       -+\t\tupstream = branch ? branch_get_upstream(branch, NULL) : NULL;\n>       ++\t\tupstream = branch_get_upstream(branch, NULL);\n>        +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n>        +\t\t\tcontinue;\n>       -+\t\tpush = branch ? branch_get_push(branch, NULL) : NULL;\n>       ++\t\tpush = branch_get_push(branch, NULL);\n>        +\t\tif (!push || !strcmp(push, upstream))\n>        +\t\t\tcontinue;\n>        +\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\tif (deletable.nr)\n>        +\t\tret = delete_branches(deletable.nr, deletable.v,\n>        +\t\t\t\t      FILTER_REFS_BRANCHES,\n>       -+\t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n>       ++\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n>        +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n>        +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>        +\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t/* possible actions */\n>         \tint delete = 0, rename = 0, copy = 0, list = 0,\n>         \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>       -+\tint prune_merged = 0;\n>       ++\tint delete_merged = 0;\n>         \tconst char *new_upstream = NULL;\n>         \tint noncreate_actions = 0;\n>         \t/* possible options */\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n>         \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n>         \t\t\t N_(\"edit the description for the branch\")),\n>       -+\t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n>       -+\t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n>       ++\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n>       ++\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n>         \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>         \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>         \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \n>         \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n>        -\t    !show_current && !unset_upstream && argc == 0)\n>       -+\t    !show_current && !unset_upstream && !prune_merged &&\n>       ++\t    !show_current && !unset_upstream && !delete_merged &&\n>        +\t    argc == 0)\n>         \t\tlist = 1;\n>         \n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>         \t\t\t    !!show_current + !!list + !!edit_description +\n>        -\t\t\t    !!unset_upstream;\n>       -+\t\t\t    !!unset_upstream + !!prune_merged;\n>       ++\t\t\t    !!unset_upstream + !!delete_merged;\n>         \tif (noncreate_actions > 1)\n>         \t\tusage_with_options(builtin_branch_usage, options);\n>         \n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n>         \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>         \t\tgoto out;\n>       -+\t} else if (prune_merged) {\n>       -+\t\tret = prune_merged_branches(argc, argv, quiet);\n>       ++\t} else if (delete_merged) {\n>       ++\t\tret = delete_merged_branches(argc, argv, quiet);\n>        +\t\tgoto out;\n>         \t} else if (show_current) {\n>         \t\tprint_current_branch_name();\n>         \t\tret = 0;\n>        \n>         ## t/t3200-branch.sh ##\n>       -@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>       - \ttest_grep \"requires a value\" err\n>       +@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n>       + \ttest_cmp expect actual\n>         '\n>         \n>       -+test_expect_success '--prune-merged: setup' '\n>       ++test_expect_success '--delete-merged: setup' '\n>        +\ttest_create_repo pm-upstream &&\n>        +\ttest_commit -C pm-upstream base &&\n>        +\tgit -C pm-upstream checkout -b next &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\ttest_create_repo pm-fork\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged deletes branches integrated into upstream' '\n>       ++test_expect_success '--delete-merged deletes branches integrated into upstream' '\n>        +\ttest_when_finished \"rm -rf pm-merged\" &&\n>        +\tgit clone pm-upstream pm-merged &&\n>        +\tgit -C pm-merged remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-merged branch two two-commit &&\n>        +\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n>        +\n>       -+\tgit -C pm-merged branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-merged branch --delete-merged \"origin/*\" &&\n>        +\n>        +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n>        +\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged accepts a literal upstream' '\n>       ++test_expect_success '--delete-merged accepts a literal upstream' '\n>        +\ttest_when_finished \"rm -rf pm-literal\" &&\n>        +\tgit clone pm-upstream pm-literal &&\n>        +\tgit -C pm-literal remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-literal branch one one-commit &&\n>        +\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n>        +\n>       -+\tgit -C pm-literal branch --prune-merged origin/next &&\n>       ++\tgit -C pm-literal branch --delete-merged origin/next &&\n>        +\n>        +\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged unions multiple <branch> arguments' '\n>       ++test_expect_success '--delete-merged unions multiple <branch> arguments' '\n>        +\ttest_when_finished \"rm -rf pm-union\" &&\n>        +\tgit clone pm-upstream pm-union &&\n>        +\tgit -C pm-union remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n>        +\tgit -C pm-union checkout --detach &&\n>        +\n>       -+\tgit -C pm-union branch --prune-merged origin/next origin/main &&\n>       ++\tgit -C pm-union branch --delete-merged origin/next origin/main &&\n>        +\n>        +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n>        +\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged accepts a local upstream' '\n>       ++test_expect_success '--delete-merged accepts a local upstream' '\n>        +\ttest_when_finished \"rm -rf pm-local\" &&\n>        +\tgit clone pm-upstream pm-local &&\n>        +\tgit -C pm-local remote add fork ../pm-fork &&\n>        +\ttest_config -C pm-local remote.pushDefault fork &&\n>        +\ttest_config -C pm-local push.default current &&\n>       -+\tgit -C pm-local checkout -b trunk &&\n>       ++\tgit -C pm-local checkout -b mainline &&\n>        +\tgit -C pm-local branch one one-commit &&\n>       -+\tgit -C pm-local branch --set-upstream-to=trunk one &&\n>       ++\tgit -C pm-local branch --set-upstream-to=mainline one &&\n>        +\tgit -C pm-local merge --ff-only one-commit &&\n>        +\n>       -+\tgit -C pm-local branch --prune-merged trunk &&\n>       ++\tgit -C pm-local branch --delete-merged mainline &&\n>        +\n>        +\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n>       ++test_expect_success '--delete-merged silently skips un-integrated commits' '\n>        +\ttest_when_finished \"rm -rf pm-unmerged\" &&\n>        +\tgit clone pm-upstream pm-unmerged &&\n>        +\tgit -C pm-unmerged remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\ttest_commit -C pm-unmerged local-only &&\n>        +\tgit -C pm-unmerged checkout - &&\n>        +\n>       -+\tgit -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n>       -+\ttest_grep \"not fully merged\" err &&\n>       -+\ttest_grep ! \"If you are sure you want to delete it\" err &&\n>       ++\tgit -C pm-unmerged branch --delete-merged \"origin/*\" 2>err &&\n>       ++\ttest_grep ! \"not fully merged\" err &&\n>        +\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '\n>       ++test_expect_success '--delete-merged is silent about not-merged-to-HEAD' '\n>        +\ttest_when_finished \"rm -rf pm-nohead\" &&\n>        +\tgit clone pm-upstream pm-nohead &&\n>        +\tgit -C pm-nohead remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-nohead branch topic one-commit &&\n>        +\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n>        +\n>       -+\tgit -C pm-nohead branch --prune-merged \"origin/*\" 2>err &&\n>       ++\tgit -C pm-nohead branch --delete-merged \"origin/*\" 2>err &&\n>        +\n>        +\ttest_grep ! \"not yet merged to HEAD\" err &&\n>        +\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged skips branches whose upstream is gone' '\n>       ++test_expect_success '--delete-merged skips branches whose upstream is gone' '\n>        +\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n>        +\tgit clone pm-upstream pm-upstream-gone &&\n>        +\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n>        +\n>        +\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n>       -+\tgit -C pm-upstream-gone branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-upstream-gone branch --delete-merged \"origin/*\" &&\n>        +\n>        +\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged never deletes the checked-out branch' '\n>       ++test_expect_success '--delete-merged never deletes the checked-out branch' '\n>        +\ttest_when_finished \"rm -rf pm-head\" &&\n>        +\tgit clone pm-upstream pm-head &&\n>        +\tgit -C pm-head remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-head checkout -b one one-commit &&\n>        +\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n>        +\n>       -+\tgit -C pm-head branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-head branch --delete-merged \"origin/*\" &&\n>        +\n>        +\tgit -C pm-head rev-parse --verify refs/heads/one\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged spares branches that push back to their upstream' '\n>       ++test_expect_success '--delete-merged spares branches that push back to their upstream' '\n>        +\ttest_when_finished \"rm -rf pm-push-eq\" &&\n>        +\tgit clone pm-upstream pm-push-eq &&\n>        +\tgit -C pm-push-eq checkout --detach &&\n>        +\n>       -+\tgit -C pm-push-eq branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-push-eq branch --delete-merged \"origin/*\" &&\n>        +\n>        +\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '\n>       ++test_expect_success '--delete-merged spares a per-branch pushRemote==upstream remote' '\n>        +\ttest_when_finished \"rm -rf pm-push-branch\" &&\n>        +\tgit clone pm-upstream pm-push-branch &&\n>        +\tgit -C pm-push-branch remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n>        +\tgit -C pm-push-branch checkout --detach &&\n>        +\n>       -+\tgit -C pm-push-branch branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-push-branch branch --delete-merged \"origin/*\" &&\n>        +\n>        +\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '\n>       ++test_expect_success '--delete-merged prunes when @{push} differs from @{upstream}' '\n>        +\ttest_when_finished \"rm -rf pm-push-diff\" &&\n>        +\tgit clone pm-upstream pm-push-diff &&\n>        +\tgit -C pm-push-diff remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n>        +\tgit -C pm-push-diff checkout --detach &&\n>        +\n>       -+\tgit -C pm-push-diff branch --prune-merged \"origin/*\" &&\n>       ++\tgit -C pm-push-diff branch --delete-merged \"origin/*\" &&\n>        +\n>        +\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged requires at least one <branch>' '\n>       -+\ttest_must_fail git -C forked branch --prune-merged 2>err &&\n>       ++test_expect_success '--delete-merged requires at least one <branch>' '\n>       ++\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n>        +\ttest_grep \"requires at least one <branch>\" err\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged takes positional <branch> arguments' '\n>       ++test_expect_success '--delete-merged takes positional <branch> arguments' '\n>        +\ttest_when_finished \"rm -rf pm-positional\" &&\n>        +\tgit clone pm-upstream pm-positional &&\n>        +\tgit -C pm-positional remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n>        +\tgit -C pm-positional checkout --detach &&\n>        +\n>       -+\tgit -C pm-positional branch --prune-merged origin/next origin/main &&\n>       ++\tgit -C pm-positional branch --delete-merged origin/next origin/main &&\n>        +\n>        +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n>        +\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n>   5:  d691d5051b ! 6:  72aaca0666 branch: add branch.<name>.pruneMerged opt-out\n>       @@ Metadata\n>        Author: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Commit message ##\n>       -    branch: add branch.<name>.pruneMerged opt-out\n>       +    branch: add branch.<name>.deleteMerged opt-out\n>        \n>       -    Setting branch.<name>.pruneMerged=false exempts that branch from\n>       -    \"git branch --prune-merged\", which is useful for a topic you want\n>       +    Setting branch.<name>.deleteMerged=false exempts that branch from\n>       +    \"git branch --delete-merged\", which is useful for a topic you want\n>            to keep developing after an early round of it has been merged\n>            upstream. Unless --quiet is given, each skip is reported so the\n>            user knows why their topic was kept.\n>       @@ Documentation/config/branch.adoc: for details).\n>         \tautomatically added to the `format-patch` cover letter or\n>         \t`request-pull` summary.\n>        +\n>       -+`branch.<name>.pruneMerged`::\n>       ++`branch.<name>.deleteMerged`::\n>        +\tIf set to `false`, branch _<name>_ is exempt from\n>       -+\t`git branch --prune-merged`.  Useful for a topic branch you\n>       ++\t`git branch --delete-merged`.  Useful for a topic branch you\n>        +\tintend to develop further after an initial round has been\n>        +\tmerged upstream.  Defaults to true.  Explicit deletion via\n>        +\t`git branch -d` is unaffected.\n>        \n>         ## Documentation/git-branch.adoc ##\n>       -@@ Documentation/git-branch.adoc: the upstream refs refreshed.\n>       +@@ Documentation/git-branch.adoc: A branch is not deleted when:\n>         +\n>       - A branch is left alone if any of the following holds:\n>       - its upstream no longer resolves locally; it is checked out in any\n>       --worktree; or its push destination (`<branch>@{push}`) equals its\n>       -+worktree; its push destination (`<branch>@{push}`) equals its\n>       - upstream (`<branch>@{upstream}`), so it cannot be distinguished\n>       --from a freshly pulled trunk that just looks \"fully merged\".\n>       -+from a freshly pulled trunk that just looks \"fully merged\"; or\n>       -+`branch.<name>.pruneMerged` is set to `false`.\n>       + --\n>       + * its upstream remote-tracking branch no longer exists,\n>       +-* it is checked out in any worktree, or\n>       ++* it is checked out in any worktree,\n>       + * its push destination (`<branch>@{push}`) equals its upstream\n>       +   (`<branch>@{upstream}`), so it cannot be distinguished from a\n>       +-  branch that just looks \"fully merged\" right after a pull.\n>       ++  branch that just looks \"fully merged\" right after a pull, or\n>       ++* `branch.<name>.deleteMerged` is set to `false`.\n>       + --\n>         +\n>       - Branches refused by the \"fully merged\" safety check are listed as\n>       - warnings and skipped; pass them to `git branch -D` explicitly if\n>       + A branch whose work has not yet been merged into its upstream is\n>        \n>         ## builtin/branch.c ##\n>       -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>       +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n>         \t\tconst char *short_name;\n>         \t\tstruct branch *branch;\n>         \t\tconst char *upstream, *push;\n>       @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>        +\t\tint opt_out;\n>         \n>         \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n>       - \t\t\tcontinue;\n>       -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>       + \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n>       +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n>         \t\tif (!push || !strcmp(push, upstream))\n>         \t\t\tcontinue;\n>         \n>       -+\t\tstrbuf_addf(&key, \"branch.%s.prunemerged\", short_name);\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n>        +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n>        +\t\t    !opt_out) {\n>        +\t\t\tif (!quiet)\n>        +\t\t\t\tfprintf(stderr,\n>       -+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.pruneMerged is false)\\n\"),\n>       ++\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n>        +\t\t\t\t\tshort_name, short_name);\n>        +\t\t\tstrbuf_release(&key);\n>        +\t\t\tcontinue;\n>       @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>         \n>        \n>         ## t/t3200-branch.sh ##\n>       -@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '\n>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '\n>         \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n>         '\n>         \n>       -+test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '\n>       ++test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n>        +\ttest_when_finished \"rm -rf pm-optout\" &&\n>        +\tgit clone pm-upstream pm-optout &&\n>        +\tgit -C pm-optout remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch>\n>        +\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n>        +\tgit -C pm-optout branch two two-commit &&\n>        +\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n>       -+\ttest_config -C pm-optout branch.one.pruneMerged false &&\n>       ++\ttest_config -C pm-optout branch.one.deleteMerged false &&\n>        +\n>       -+\tgit -C pm-optout branch --prune-merged \"origin/*\" 2>err &&\n>       ++\tgit -C pm-optout branch --delete-merged \"origin/*\" 2>err &&\n>        +\n>        +\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n>        +\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n>        +\ttest_grep \"Skipping .one.\" err\n>        +'\n>        +\n>       -+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n>       ++test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n>        +\ttest_when_finished \"rm -rf pm-optout-d\" &&\n>        +\tgit clone pm-upstream pm-optout-d &&\n>        +\tgit -C pm-optout-d branch one one-commit &&\n>        +\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n>       -+\ttest_config -C pm-optout-d branch.one.pruneMerged false &&\n>       ++\ttest_config -C pm-optout-d branch.one.deleteMerged false &&\n>        +\n>        +\tgit -C pm-optout-d branch -d one &&\n>        +\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>   6:  ede8c61729 ! 7:  7b2b01b988 branch: add --dry-run for --prune-merged\n>       @@ Metadata\n>        Author: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>         ## Commit message ##\n>       -    branch: add --dry-run for --prune-merged\n>       +    branch: add --dry-run for --delete-merged\n>        \n>       -    With --dry-run, --prune-merged prints the local branches it would\n>       +    With --dry-run, --delete-merged prints the local branches it would\n>            delete, one \"Would delete branch <name>\" line each, and exits\n>            without touching any ref. The same filtering applies, so the output\n>            is exactly the set that the real run would delete.\n>        \n>       -    --dry-run is only meaningful together with --prune-merged and is\n>       +    --dry-run is only meaningful together with --delete-merged and is\n>            rejected otherwise.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n>         git branch (-c|-C) [<old-branch>] <new-branch>\n>         git branch (-d|-D) [-r] <branch-name>...\n>         git branch --edit-description [<branch-name>]\n>       --git branch --prune-merged <branch>...\n>       -+git branch [--dry-run] --prune-merged <branch>...\n>       +-git branch --delete-merged <branch>...\n>       ++git branch [--dry-run] --delete-merged <branch>...\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: Branches refused by the \"fully merged\" safety check are listed as\n>       - warnings and skipped; pass them to `git branch -D` explicitly if\n>       - you want them gone.\n>       +@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is\n>       + silently skipped. Delete it with `git branch -D` if you want to\n>       + remove it anyway.\n>         \n>        +`--dry-run`::\n>       -+\tWith `--prune-merged`, print which branches would be\n>       ++\tWith `--delete-merged`, print which branches would be\n>        +\tdeleted and exit without touching any ref.  Useful for\n>        +\tsanity-checking a wide pattern like `'origin/*'` before\n>        +\tcommitting to the deletion.\n>       @@ builtin/branch.c\n>        @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n>         }\n>         \n>       - static int prune_merged_branches(int argc, const char **argv,\n>       + static int delete_merged_branches(int argc, const char **argv,\n>        -\t\t\t\t int quiet)\n>        +\t\t\t\t int quiet, int dry_run)\n>         {\n>         \tstruct ref_store *refs = get_main_ref_store(the_repository);\n>         \tstruct ref_filter filter = REF_FILTER_INIT;\n>       -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>       +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n>         \t\t\t\t      FILTER_REFS_BRANCHES,\n>       - \t\t\t\t      DELETE_BRANCH_WARN_ONLY |\n>       + \t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n>         \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n>        -\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>        +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n>       @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tint delete = 0, rename = 0, copy = 0, list = 0,\n>         \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>       - \tint prune_merged = 0;\n>       + \tint delete_merged = 0;\n>        +\tint dry_run = 0;\n>         \tconst char *new_upstream = NULL;\n>         \tint noncreate_actions = 0;\n>         \t/* possible options */\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\t\t N_(\"edit the description for the branch\")),\n>       - \t\tOPT_BOOL(0, \"prune-merged\", &prune_merged,\n>       - \t\t\tN_(\"delete local branches whose upstream matches <branch> and is merged\")),\n>       + \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n>       + \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n>        +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n>       -+\t\t\tN_(\"with --prune-merged, only print which branches would be deleted\")),\n>       ++\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n>         \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>         \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>         \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tif (noncreate_actions > 1)\n>         \t\tusage_with_options(builtin_branch_usage, options);\n>         \n>       -+\tif (dry_run && !prune_merged)\n>       -+\t\tdie(_(\"--dry-run requires --prune-merged\"));\n>       ++\tif (dry_run && !delete_merged)\n>       ++\t\tdie(_(\"--dry-run requires --delete-merged\"));\n>        +\n>         \tif (recurse_submodules_explicit) {\n>         \t\tif (!submodule_propagate_branches)\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>         \t\tgoto out;\n>       - \t} else if (prune_merged) {\n>       --\t\tret = prune_merged_branches(argc, argv, quiet);\n>       -+\t\tret = prune_merged_branches(argc, argv, quiet, dry_run);\n>       + \t} else if (delete_merged) {\n>       +-\t\tret = delete_merged_branches(argc, argv, quiet);\n>       ++\t\tret = delete_merged_branches(argc, argv, quiet, dry_run);\n>         \t\tgoto out;\n>         \t} else if (show_current) {\n>         \t\tprint_current_branch_name();\n>        \n>         ## t/t3200-branch.sh ##\n>       -@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=false branch' '\n>       +@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n>         \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n>         '\n>         \n>       -+test_expect_success '--prune-merged --dry-run lists but does not delete' '\n>       ++test_expect_success '--delete-merged --dry-run lists but does not delete' '\n>        +\ttest_when_finished \"rm -rf pm-dry\" &&\n>        +\tgit clone pm-upstream pm-dry &&\n>        +\tgit -C pm-dry remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n>        +\tgit -C pm-dry branch two two-commit &&\n>        +\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n>        +\n>       -+\tgit -C pm-dry branch --dry-run --prune-merged \"origin/*\" >actual &&\n>       ++\tgit -C pm-dry branch --dry-run --delete-merged \"origin/*\" >actual &&\n>        +\ttest_grep \"Would delete branch one \" actual &&\n>        +\ttest_grep \"Would delete branch two \" actual &&\n>        +\n>       @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n>        +\tgit -C pm-dry rev-parse --verify refs/heads/two\n>        +'\n>        +\n>       -+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '\n>       ++test_expect_success '--delete-merged --dry-run only lists branches the live run would delete' '\n>        +\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n>        +\tgit clone pm-upstream pm-dry-mixed &&\n>        +\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n>       @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa\n>        +\tgit -C pm-dry-mixed branch merged one-commit &&\n>        +\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n>        +\n>       -+\tgit -C pm-dry-mixed branch --dry-run --prune-merged \"origin/*\" >out &&\n>       ++\tgit -C pm-dry-mixed branch --dry-run --delete-merged \"origin/*\" >out &&\n>        +\ttest_grep \"Would delete branch merged\" out &&\n>        +\ttest_grep ! \"Would delete branch wip\" out &&\n>        +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n>        +\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n>        +'\n>        +\n>       -+test_expect_success '--dry-run without --prune-merged is rejected' '\n>       ++test_expect_success '--dry-run without --delete-merged is rejected' '\n>        +\ttest_must_fail git -C forked branch --dry-run 2>err &&\n>       -+\ttest_grep \"requires --prune-merged\" err\n>       ++\ttest_grep \"requires --delete-merged\" err\n>        +'\n>        +\n>         test_done\n> \n\n"},{"id":"545756","messageId":"CAHwyqnXRo=P5Zihs6s7Uh8CrYCO7mjyeZ5nAv9JqYbGH0RE72g@mail.gmail.com","threadId":"65574","inReplyTo":"f68e2a11-02a5-47b9-a01a-458eba821c37@gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-17T11:17:08Z","receivedAt":"2026-06-17T11:17:47Z","isPatch":true,"body":"On Wed, Jun 17, 2026 at 12:01 PM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> Hi Harald\n>\n> Our SubmittingPatches documentation recommends waiting for the\n> discussion to settle before sending a new version. When you know someone\n> is going send more comments on a series it is a good idea to wait for\n> them before sending a new version to avoid too much churn on the list\n> which makes it hard for people to keep up. I'm not going to read this\n> version in detail because I know another version will be needed but I\n> did spot a couple of things in the summary below.\n\nGot it. I think I am waiting a fair bit between sending new versions.\nMy last version here was 2 days ago.\n\n> Not changing force sounds like a bad idea. The whole point of unpacking\n> the flags at the start of the function is to avoid accidental\n> regressions. Unpacking the flags into separate variables means the rest\n> of the function does not need to know that the function arguments have\n> changed.\n\nMy reason for keeping it like this was to avoid the slightly awkward\ndouble re-assignment of both flag and boolean:\n\n```\ncase FILTER_REFS_REMOTES:\n...\n    flags |= DELETE_BRANCH_FORCE;\n    force = true;\n```\n\nBut your way is likely still better, because the definitions at the\ntop of the function are clearer.\n\n\nHarald\n"},{"id":"545766","messageId":"5829103e-d357-4880-b295-fa0d9f4a2c62@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnXRo=P5Zihs6s7Uh8CrYCO7mjyeZ5nAv9JqYbGH0RE72g@mail.gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-17T14:21:53Z","receivedAt":"2026-06-17T14:22:28Z","isPatch":true,"body":"On 17/06/2026 12:17, Harald Nordgren wrote:\n> On Wed, Jun 17, 2026 at 12:01 PM Phillip Wood <phillip.wood123@gmail.com> wrote:\n > >> Our SubmittingPatches documentation recommends waiting for the\n>> discussion to settle before sending a new version. When you know someone\n>> is going send more comments on a series it is a good idea to wait for\n>> them before sending a new version to avoid too much churn on the list\n>> which makes it hard for people to keep up. I'm not going to read this\n>> version in detail because I know another version will be needed but I\n>> did spot a couple of things in the summary below.\n> \n> Got it. I think I am waiting a fair bit between sending new versions.\n> My last version here was 2 days ago.\n\nRight but you sent that version a few hours after I'd posted a partial \nreview which concluded by saying I'd finish it the next day. If you send \na new version when you are waiting for further comments it clutters the \nlist because you know you're going to have to post another revision when \nyou get the rest of the comments. Anyone reviewing the interim version \nis wasting their time. When you receive review comments, by all means \nstart thinking about them and updating your local copy but please don't \npost a new version until the discussion on the previous version has \nsettled down.\n\n>> Not changing force sounds like a bad idea. The whole point of unpacking\n>> the flags at the start of the function is to avoid accidental\n>> regressions. Unpacking the flags into separate variables means the rest\n>> of the function does not need to know that the function arguments have\n>> changed.\n> \n> My reason for keeping it like this was to avoid the slightly awkward\n> double re-assignment of both flag and boolean:\n> \n> ```\n> case FILTER_REFS_REMOTES:\n> ...\n>      flags |= DELETE_BRANCH_FORCE;\n>      force = true;\n> ```\n> \n> But your way is likely still better, because the definitions at the\n> top of the function are clearer.\n\nOh, are we passing flags on to another function? If so I'd missed that \nand it does complicate things as we don't want two sources of truth.\n\nThanks\n\nPhillip\n\n> \n> Harald\n\n"},{"id":"545791","messageId":"CAHwyqnWFM2jskm6soEu58tp_TgO3fmuODD-yTiK6-4Hpv8SMLQ@mail.gmail.com","threadId":"65574","inReplyTo":"5829103e-d357-4880-b295-fa0d9f4a2c62@gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-17T19:11:18Z","receivedAt":"2026-06-17T19:11:56Z","isPatch":true,"body":"> Right but you sent that version a few hours after I'd posted a partial\n> review which concluded by saying I'd finish it the next day. If you send\n> a new version when you are waiting for further comments it clutters the\n> list because you know you're going to have to post another revision when\n> you get the rest of the comments. Anyone reviewing the interim version\n> is wasting their time. When you receive review comments, by all means\n> start thinking about them and updating your local copy but please don't\n> post a new version until the discussion on the previous version has\n> settled down.\n\nThat's fair. Sorry about that.\n\nWill you let me know when your review here is finished?\n\nI received the same feedback from Junio before, so I'm not unaware of\nthis problem. I am trying to slow down. I often prepare the work as\nsoon as I get some comments -- I'm on paternity leave so I have a lot\nof time when the baby is sleeping -- then I actively hold off on\nsending to not overload the rest of you. But at the same time I think\nit's valuable to keep up a certain pace. It's a balancing act.\n\n\nHarald\n"},{"id":"545846","messageId":"37f2a483-c8bf-4c24-84de-c6233cc20b25@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnUsjpCHfS=eBphmkdDGYpQZ_LQUJi1mjrxV8ZXi+w4yhg@mail.gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-18T13:42:25Z","receivedAt":"2026-06-18T13:42:29Z","isPatch":true,"body":"Hi Harald\n\nOn 16/06/2026 20:15, Harald Nordgren wrote:\n>>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n>>> index 4e7deddc04..27ea1319bb 100755\n>>> --- a/t/t3200-branch.sh\n>>> +++ b/t/t3200-branch.sh\n>>> @@ -1809,4 +1809,205 @@ test_expect_success '--forked requires a value' '\n>>>        test_grep \"requires a value\" err\n>>>    '\n>>>\n>>> +test_expect_success '--prune-merged: setup' '\n>>> +     test_create_repo pm-upstream &&\n>>\n>> The rest of this test would be easier to read if we did\n>>\n>>          (\n>>                  cd pm-upstream &&\n>>                  ...\n>>          )\n>>\n>> rather than prefixing every command with \"-C pm-upstream\"\n> \n> I feel like the discussion to nest or not to nest has come up many\n> times in other topics as well. I don't feel strongly about either way,\n> but I just want to flag that if I change it now, another reviewer\n> might ask me to change it back later.\n> \n> Should the rules be to nest inside of setup functions (and helpers?)\n> but not inside the actual tests?\n\nI think it depends on how many commands you're running in a row in the \nsame directory. In this case we're running quite a few commands so it \nseems clearer to use a subshell. In the later tests we're switching \nbetween repositories and running fewer commands in each one so it is \nless clear that using subshells is clearer. Also later on we're using \ntest_config() which I don't think works in a subshell because it relies \non test_when_finished().\n\nOne thing I've just thought of related to this patch is whether we want \nto protect branches that are the upstreams of branches that are not \nslated for deletion. With stacked branches it is possible that a branch \nhas been merged but has other branches stacked on top of it that have \nnot been merged. If we build an strset of branches that we want to \ndelete, then loop over all branches and if there are any that are not in \nthe to be deleted set which have their upstream in that set we'd remove \nthe upstream branch from the set. Once we've done that we can convert \nthe set to an strvec to pass to delete_branches()\n\nThanks\n\nPhillip\n\n> \n>>> +     test_commit -C pm-upstream base &&\n>>> +     git -C pm-upstream checkout -b next &&\n>>> +     test_commit -C pm-upstream one-commit &&\n>>> +     test_commit -C pm-upstream two-commit &&\n>>> +     git -C pm-upstream branch one HEAD~ &&\n>>> +     git -C pm-upstream branch two HEAD &&\n>>> +     git -C pm-upstream branch wip main &&\n>>> +     git -C pm-upstream checkout main &&\n>>> +     test_create_repo pm-fork\n>>> +'\n>>> +\n>>> +test_expect_success '--prune-merged deletes branches integrated into upstream' '\n>>> +     test_when_finished \"rm -rf pm-merged\" &&\n>>> +     git clone pm-upstream pm-merged &&\n>>> +     git -C pm-merged remote add fork ../pm-fork &&\n>>> +     test_config -C pm-merged remote.pushDefault fork &&\n>>> +     test_config -C pm-merged push.default current &&\n>>\n>> So we clone upstream and add fork as the default push remote. I find the\n>> pm- prefixes rather distracting. It would be clearer to me if we just\n>> called the repositories \"upstream\", \"fork\" and \"repo\"\n> \n> Good point.\n> \n>>> +     test_must_fail git -C pm-local rev-parse --verify refs/heads/one\n>>> +'\n>>> +\n>>> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '\n>>> +     test_when_finished \"rm -rf pm-unmerged\" &&\n>>> +     git clone pm-upstream pm-unmerged &&\n>>> +     git -C pm-unmerged remote add fork ../pm-fork &&\n>>> +     test_config -C pm-unmerged remote.pushDefault fork &&\n>>> +     test_config -C pm-unmerged push.default current &&\n>>> +     git -C pm-unmerged checkout -b wip origin/wip &&\n>>> +     git -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n>>> +     test_commit -C pm-unmerged local-only &&\n>>> +     git -C pm-unmerged checkout - &&\n>>> +\n>>> +     git -C pm-unmerged branch --prune-merged \"origin/*\" 2>err &&\n>>> +     test_grep \"not fully merged\" err &&\n>>> +     test_grep ! \"If you are sure you want to delete it\" err &&\n>>\n>> I'm always suspicious of test_grep when we know what the output should\n>> look like - it might be better to use test_cmp. This test does not check\n>> that we also delete branches that are merged when we see one that isn't.\n>>\n>> I'm going to stop here - the tests I've read seem to me to be too much\n>> like unit tests checking one aspect of the implementation in isolation\n>> rather than checking that the whole feature works as expected.\n> \n> I'll respond to the rest here: Excellent points regarding the testing\n> aboce, I will take a look at doing this.\n> \n> \n> Harald\n> \n\n"},{"id":"545847","messageId":"feac3d8b-e291-48e8-ac73-3b1f5321799b@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnWFM2jskm6soEu58tp_TgO3fmuODD-yTiK6-4Hpv8SMLQ@mail.gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-18T13:48:57Z","receivedAt":"2026-06-18T13:49:02Z","isPatch":true,"body":"Hi Harald\n\nOn 17/06/2026 20:11, Harald Nordgren wrote:\n>> Right but you sent that version a few hours after I'd posted a partial\n>> review which concluded by saying I'd finish it the next day. If you send\n>> a new version when you are waiting for further comments it clutters the\n>> list because you know you're going to have to post another revision when\n>> you get the rest of the comments. Anyone reviewing the interim version\n>> is wasting their time. When you receive review comments, by all means\n>> start thinking about them and updating your local copy but please don't\n>> post a new version until the discussion on the previous version has\n>> settled down.\n> \n> That's fair. Sorry about that.\n> \n> Will you let me know when your review here is finished?\n\nI've just sent a mail with another comment but that concudes this round \nunless you have any questions about it.\n\n> \n> I received the same feedback from Junio before, so I'm not unaware of\n> this problem. I am trying to slow down. I often prepare the work as\n> soon as I get some comments -- I'm on paternity leave so I have a lot\n> of time when the baby is sleeping -- \n\nCongratulations - I hope the baby is sleeping at night as well in the day!\n\n> then I actively hold off on\n> sending to not overload the rest of you. But at the same time I think\n> it's valuable to keep up a certain pace. It's a balancing act.\nIt is worth waiting for the discussion to settle on each round, I'll try \nand be clear when I've finished looking at each revision. I'm sure other \nfolks would appreciate you looking at their patches and commenting on \nthem while you're waiting for feedback on yours, especially the GSoC \nproject students.\n\nThanks\n\nPhillip\n"},{"id":"545866","messageId":"xmqqcxxnsufl.fsf@gitster.g","threadId":"65574","inReplyTo":"37f2a483-c8bf-4c24-84de-c6233cc20b25@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-18T16:08:14Z","receivedAt":"2026-06-18T16:08:17Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> One thing I've just thought of related to this patch is whether we want \n> to protect branches that are the upstreams of branches that are not \n> slated for deletion. With stacked branches it is possible that a branch \n> has been merged but has other branches stacked on top of it that have \n> not been merged.\n\nAn interesting point.  We do have \"this topic is built on the result\nof merging these other topics into main\" and I expect the practice\nis wide spread.  These base topics may graduate first, but other\ntopics may still be updated.\n\nBut when you rewrite these other topics, wouldn't you leave their\nbases untouched?  IOW, a new iteration (i.e. \"rebase -i\") would\nreuse the base that was used in an earlier iteration, i.e. the\nresult of an earlier merge of the other topics, some of which might\nhave been pruned since then, into an older 'main', so it is OK to\nlose these other topics once they have graduated, simply because you\nwouldn't be recreating the merge that you used as the base of this\nremaining topic, no?\n\nOr am I missing something?\n\nThanks.\n"},{"id":"545875","messageId":"CAHwyqnW-rizVteUiPB_dVRBm3c8NRAV4TP5uPd9koL38sokW_Q@mail.gmail.com","threadId":"65574","inReplyTo":"feac3d8b-e291-48e8-ac73-3b1f5321799b@gmail.com","subject":"Re: [PATCH v15 0/7] branch: delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-18T17:53:18Z","receivedAt":"2026-06-18T17:53:58Z","isPatch":true,"body":"> > I received the same feedback from Junio before, so I'm not unaware of\n> > this problem. I am trying to slow down. I often prepare the work as\n> > soon as I get some comments -- I'm on paternity leave so I have a lot\n> > of time when the baby is sleeping --\n>\n> Congratulations - I hope the baby is sleeping at night as well in the day!\n\nThanks! It's our third, so hopefully we got the hang of it now.\n\nHe sleeps -- some of the time.\n\n> > then I actively hold off on\n> > sending to not overload the rest of you. But at the same time I think\n> > it's valuable to keep up a certain pace. It's a balancing act.\n> It is worth waiting for the discussion to settle on each round, I'll try\n> and be clear when I've finished looking at each revision. I'm sure other\n> folks would appreciate you looking at their patches and commenting on\n> them while you're waiting for feedback on yours, especially the GSoC\n> project students.\n\nThat's a good point!\n\n\nHarald\n"},{"id":"545892","messageId":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v15.git.git.1781542042.gitgitgadget@gmail.com","subject":"[PATCH v16 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:22Z","receivedAt":"2026-06-18T19:25:32Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  43 ++++-\n builtin/branch.c                 | 186 ++++++++++++++++++----\n ref-filter.c                     |  70 +++++++++\n ref-filter.h                     |  10 ++\n t/t3200-branch.sh                | 262 +++++++++++++++++++++++++++++++\n 6 files changed, 550 insertions(+), 28 deletions(-)\n\n\nbase-commit: 4621f8ce5e9b97aa2e8d0d9ffe9d25df2471074d\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v16\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v16\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v15:\n\n 1:  da741b5ea7 ! 1:  1f6a758265 branch: add --forked filter for --list mode\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n       \n      +test_expect_success '--forked: setup' '\n      +\ttest_create_repo forked-upstream &&\n     -+\ttest_commit -C forked-upstream base &&\n     -+\tgit -C forked-upstream branch one base &&\n     -+\tgit -C forked-upstream branch two base &&\n     ++\t(\n     ++\t\tcd forked-upstream &&\n     ++\t\ttest_commit base &&\n     ++\t\tgit branch one base &&\n     ++\t\tgit branch two base\n     ++\t) &&\n      +\n      +\ttest_create_repo forked-other &&\n     -+\ttest_commit -C forked-other other-base &&\n     -+\tgit -C forked-other branch foreign other-base &&\n     ++\t(\n     ++\t\tcd forked-other &&\n     ++\t\ttest_commit other-base &&\n     ++\t\tgit branch foreign other-base\n     ++\t) &&\n      +\n      +\tgit clone forked-upstream forked &&\n     -+\tgit -C forked remote add -f other ../forked-other &&\n     -+\tgit -C forked remote set-head origin one &&\n     -+\tgit -C forked branch local-base &&\n     -+\tgit -C forked branch --track local-one origin/one &&\n     -+\tgit -C forked branch --track local-two origin/two &&\n     -+\tgit -C forked branch --track local-foreign other/foreign &&\n     -+\tgit -C forked branch --track local-onbase local-base &&\n     ++\t(\n     ++\t\tcd forked &&\n     ++\t\tgit remote add -f other ../forked-other &&\n     ++\t\tgit remote set-head origin one &&\n     ++\t\tgit branch local-base &&\n     ++\t\tgit branch --track local-one origin/one &&\n     ++\t\tgit branch --track local-two origin/two &&\n     ++\t\tgit branch --track local-foreign other/foreign &&\n     ++\t\tgit branch --track local-onbase local-base &&\n      +\n     -+\tgit -C forked checkout local-one &&\n     -+\ttest_commit -C forked --no-tag local-one-work local-one.t &&\n     -+\tgit -C forked checkout local-foreign &&\n     -+\ttest_commit -C forked --no-tag local-foreign-work local-foreign.t &&\n     -+\tgit -C forked checkout --detach\n     ++\t\tgit checkout local-one &&\n     ++\t\ttest_commit --no-tag local-one-work local-one.t &&\n     ++\t\tgit checkout local-foreign &&\n     ++\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n     ++\t\tgit checkout --detach\n     ++\t)\n      +'\n      +\n      +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n 2:  91c35f10cc = 2:  4f8af602ba branch: convert delete_branches() to a flags argument\n 3:  e101dd2886 = 3:  efc891c255 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  6c3534901a = 4:  b1ecd38fe3 branch: prepare delete_branches for a bulk caller\n 5:  5899013b8f ! 5:  998fb6a68c branch: add --delete-merged <branch>\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n       }\n       \n      +static int delete_merged_branches(int argc, const char **argv,\n     -+\t\t\t\t int quiet)\n     ++\t\t\t\t unsigned int flags)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\t\t\t      FILTER_REFS_BRANCHES,\n      +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n      +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     -+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n     ++\t\t\t\t      flags);\n      +\n      +\tstrvec_clear(&deletable);\n      +\tref_array_clear(&candidates);\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n      +\t} else if (delete_merged) {\n     -+\t\tret = delete_merged_branches(argc, argv, quiet);\n     ++\t\tret = delete_merged_branches(argc, argv,\n     ++\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n     @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n       '\n       \n      +test_expect_success '--delete-merged: setup' '\n     -+\ttest_create_repo pm-upstream &&\n     -+\ttest_commit -C pm-upstream base &&\n     -+\tgit -C pm-upstream checkout -b next &&\n     -+\ttest_commit -C pm-upstream one-commit &&\n     -+\ttest_commit -C pm-upstream two-commit &&\n     -+\tgit -C pm-upstream branch one HEAD~ &&\n     -+\tgit -C pm-upstream branch two HEAD &&\n     -+\tgit -C pm-upstream branch wip main &&\n     -+\tgit -C pm-upstream checkout main &&\n     -+\ttest_create_repo pm-fork\n     ++\tgit init -b main upstream &&\n     ++\t(\n     ++\t\tcd upstream &&\n     ++\t\ttest_commit base &&\n     ++\t\tgit checkout -b next &&\n     ++\t\ttest_commit next-work &&\n     ++\t\tgit checkout main\n     ++\t) &&\n     ++\tgit init -b main other &&\n     ++\ttest_commit -C other other-base &&\n     ++\tgit init -b main fork\n      +'\n      +\n     -+test_expect_success '--delete-merged deletes branches integrated into upstream' '\n     -+\ttest_when_finished \"rm -rf pm-merged\" &&\n     -+\tgit clone pm-upstream pm-merged &&\n     -+\tgit -C pm-merged remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-merged remote.pushDefault fork &&\n     -+\ttest_config -C pm-merged push.default current &&\n     -+\tgit -C pm-merged branch one one-commit &&\n     -+\tgit -C pm-merged branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-merged branch two two-commit &&\n     -+\tgit -C pm-merged branch --set-upstream-to=origin/next two &&\n     -+\n     -+\tgit -C pm-merged branch --delete-merged \"origin/*\" &&\n     -+\n     -+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&\n     -+\ttest_must_fail git -C pm-merged rev-parse --verify refs/heads/two\n     -+'\n     -+\n     -+test_expect_success '--delete-merged accepts a literal upstream' '\n     -+\ttest_when_finished \"rm -rf pm-literal\" &&\n     -+\tgit clone pm-upstream pm-literal &&\n     -+\tgit -C pm-literal remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-literal remote.pushDefault fork &&\n     -+\ttest_config -C pm-literal push.default current &&\n     -+\tgit -C pm-literal branch one one-commit &&\n     -+\tgit -C pm-literal branch --set-upstream-to=origin/next one &&\n     -+\n     -+\tgit -C pm-literal branch --delete-merged origin/next &&\n     -+\n     -+\ttest_must_fail git -C pm-literal rev-parse --verify refs/heads/one\n     -+'\n     -+\n     -+test_expect_success '--delete-merged unions multiple <branch> arguments' '\n     -+\ttest_when_finished \"rm -rf pm-union\" &&\n     -+\tgit clone pm-upstream pm-union &&\n     -+\tgit -C pm-union remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-union remote.pushDefault fork &&\n     -+\ttest_config -C pm-union push.default current &&\n     -+\tgit -C pm-union branch one one-commit &&\n     -+\tgit -C pm-union branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-union branch two base &&\n     -+\tgit -C pm-union branch --set-upstream-to=origin/main two &&\n     -+\tgit -C pm-union checkout --detach &&\n     -+\n     -+\tgit -C pm-union branch --delete-merged origin/next origin/main &&\n     -+\n     -+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/one &&\n     -+\ttest_must_fail git -C pm-union rev-parse --verify refs/heads/two\n     -+'\n     -+\n     -+test_expect_success '--delete-merged accepts a local upstream' '\n     -+\ttest_when_finished \"rm -rf pm-local\" &&\n     -+\tgit clone pm-upstream pm-local &&\n     -+\tgit -C pm-local remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-local remote.pushDefault fork &&\n     -+\ttest_config -C pm-local push.default current &&\n     -+\tgit -C pm-local checkout -b mainline &&\n     -+\tgit -C pm-local branch one one-commit &&\n     -+\tgit -C pm-local branch --set-upstream-to=mainline one &&\n     -+\tgit -C pm-local merge --ff-only one-commit &&\n     -+\n     -+\tgit -C pm-local branch --delete-merged mainline &&\n     -+\n     -+\ttest_must_fail git -C pm-local rev-parse --verify refs/heads/one\n     -+'\n     -+\n     -+test_expect_success '--delete-merged silently skips un-integrated commits' '\n     -+\ttest_when_finished \"rm -rf pm-unmerged\" &&\n     -+\tgit clone pm-upstream pm-unmerged &&\n     -+\tgit -C pm-unmerged remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-unmerged remote.pushDefault fork &&\n     -+\ttest_config -C pm-unmerged push.default current &&\n     -+\tgit -C pm-unmerged checkout -b wip origin/wip &&\n     -+\tgit -C pm-unmerged branch --set-upstream-to=origin/next wip &&\n     -+\ttest_commit -C pm-unmerged local-only &&\n     -+\tgit -C pm-unmerged checkout - &&\n     -+\n     -+\tgit -C pm-unmerged branch --delete-merged \"origin/*\" 2>err &&\n     -+\ttest_grep ! \"not fully merged\" err &&\n     -+\tgit -C pm-unmerged rev-parse --verify refs/heads/wip\n     -+'\n     -+\n     -+test_expect_success '--delete-merged is silent about not-merged-to-HEAD' '\n     -+\ttest_when_finished \"rm -rf pm-nohead\" &&\n     -+\tgit clone pm-upstream pm-nohead &&\n     -+\tgit -C pm-nohead remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-nohead remote.pushDefault fork &&\n     -+\ttest_config -C pm-nohead push.default current &&\n     -+\tgit -C pm-nohead branch topic one-commit &&\n     -+\tgit -C pm-nohead branch --set-upstream-to=origin/next topic &&\n     -+\n     -+\tgit -C pm-nohead branch --delete-merged \"origin/*\" 2>err &&\n     -+\n     -+\ttest_grep ! \"not yet merged to HEAD\" err &&\n     -+\ttest_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic\n     -+'\n     -+\n     -+test_expect_success '--delete-merged skips branches whose upstream is gone' '\n     -+\ttest_when_finished \"rm -rf pm-upstream-gone\" &&\n     -+\tgit clone pm-upstream pm-upstream-gone &&\n     -+\tgit -C pm-upstream-gone remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-upstream-gone remote.pushDefault fork &&\n     -+\ttest_config -C pm-upstream-gone push.default current &&\n     -+\tgit -C pm-upstream-gone branch one one-commit &&\n     -+\tgit -C pm-upstream-gone branch --set-upstream-to=origin/next one &&\n     -+\n     -+\tgit -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&\n     -+\tgit -C pm-upstream-gone branch --delete-merged \"origin/*\" &&\n     -+\n     -+\tgit -C pm-upstream-gone rev-parse --verify refs/heads/one\n     -+'\n     -+\n     -+test_expect_success '--delete-merged never deletes the checked-out branch' '\n     -+\ttest_when_finished \"rm -rf pm-head\" &&\n     -+\tgit clone pm-upstream pm-head &&\n     -+\tgit -C pm-head remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-head remote.pushDefault fork &&\n     -+\ttest_config -C pm-head push.default current &&\n     -+\tgit -C pm-head checkout -b one one-commit &&\n     -+\tgit -C pm-head branch --set-upstream-to=origin/next one &&\n     -+\n     -+\tgit -C pm-head branch --delete-merged \"origin/*\" &&\n     -+\n     -+\tgit -C pm-head rev-parse --verify refs/heads/one\n     -+'\n     -+\n     -+test_expect_success '--delete-merged spares branches that push back to their upstream' '\n     -+\ttest_when_finished \"rm -rf pm-push-eq\" &&\n     -+\tgit clone pm-upstream pm-push-eq &&\n     -+\tgit -C pm-push-eq checkout --detach &&\n     -+\n     -+\tgit -C pm-push-eq branch --delete-merged \"origin/*\" &&\n     -+\n     -+\tgit -C pm-push-eq rev-parse --verify refs/heads/main\n     -+'\n     -+\n     -+test_expect_success '--delete-merged spares a per-branch pushRemote==upstream remote' '\n     -+\ttest_when_finished \"rm -rf pm-push-branch\" &&\n     -+\tgit clone pm-upstream pm-push-branch &&\n     -+\tgit -C pm-push-branch remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-push-branch remote.pushDefault fork &&\n     -+\ttest_config -C pm-push-branch push.default current &&\n     -+\ttest_config -C pm-push-branch branch.main.pushRemote origin &&\n     -+\tgit -C pm-push-branch checkout --detach &&\n     ++setup_repo_for_delete_merged () {\n     ++\trm -rf repo &&\n     ++\tgit clone upstream repo &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit remote add fork ../fork &&\n     ++\t\tgit remote add other ../other &&\n     ++\t\tgit config remote.pushDefault fork &&\n     ++\t\tgit config push.default current &&\n     ++\t\tgit fetch other\n     ++\t)\n     ++}\n      +\n     -+\tgit -C pm-push-branch branch --delete-merged \"origin/*\" &&\n     ++merged_branch () {\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b \"$1\" \"$2\" &&\n     ++\t\tgit commit --allow-empty -m \"$1 work\" &&\n     ++\t\tgit push origin \"$1:next\" &&\n     ++\t\tgit fetch origin &&\n     ++\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n     ++\t)\n     ++}\n      +\n     -+\tgit -C pm-push-branch rev-parse --verify refs/heads/main\n     ++test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tmerged_branch merged origin/next &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b unmerged origin/next &&\n     ++\t\tgit commit --allow-empty -m \"unmerged work\" &&\n     ++\t\tgit branch --set-upstream-to=origin/next unmerged &&\n     ++\t\tgit checkout -b tracks-other other/main &&\n     ++\t\tgit branch --set-upstream-to=other/main tracks-other &&\n     ++\t\tgit checkout --detach\n     ++\t) &&\n     ++\tsha=$(git -C repo rev-parse --short merged) &&\n     ++\n     ++\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     ++\n     ++\techo \"Deleted branch merged (was $sha).\" >expect &&\n     ++\ttest_cmp expect actual &&\n     ++\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tmain\n     ++\ttracks-other\n     ++\tunmerged\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--delete-merged prunes when @{push} differs from @{upstream}' '\n     -+\ttest_when_finished \"rm -rf pm-push-diff\" &&\n     -+\tgit clone pm-upstream pm-push-diff &&\n     -+\tgit -C pm-push-diff remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-push-diff remote.pushDefault fork &&\n     -+\ttest_config -C pm-push-diff push.default current &&\n     -+\tgit -C pm-push-diff branch topic one-commit &&\n     -+\tgit -C pm-push-diff branch --set-upstream-to=origin/next topic &&\n     -+\tgit -C pm-push-diff checkout --detach &&\n     -+\n     -+\tgit -C pm-push-diff branch --delete-merged \"origin/*\" &&\n     -+\n     -+\ttest_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic\n     ++test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tmerged_branch on-next origin/next &&\n     ++\tmerged_branch checked-out origin/next &&\n     ++\tmerged_branch upstream-gone origin/next &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b mainline main &&\n     ++\t\tgit checkout -b on-local mainline &&\n     ++\t\tgit branch --set-upstream-to=mainline on-local &&\n     ++\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n     ++\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n     ++\t\tgit update-ref -d refs/remotes/origin/topic &&\n     ++\t\tgit branch --set-upstream-to=origin/main main &&\n     ++\t\tgit config branch.main.pushRemote origin &&\n     ++\t\tgit checkout -b tracks-other other/main &&\n     ++\t\tgit branch --set-upstream-to=other/main tracks-other &&\n     ++\t\tgit checkout checked-out\n     ++\t) &&\n     ++\n     ++\tgit -C repo branch --delete-merged origin/next mainline &&\n     ++\n     ++\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tchecked-out\n     ++\tmain\n     ++\tmainline\n     ++\ttracks-other\n     ++\tupstream-gone\n     ++\tEOF\n     ++\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--delete-merged requires at least one <branch>' '\n      +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n      +\ttest_grep \"requires at least one <branch>\" err\n      +'\n     -+\n     -+test_expect_success '--delete-merged takes positional <branch> arguments' '\n     -+\ttest_when_finished \"rm -rf pm-positional\" &&\n     -+\tgit clone pm-upstream pm-positional &&\n     -+\tgit -C pm-positional remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-positional remote.pushDefault fork &&\n     -+\ttest_config -C pm-positional push.default current &&\n     -+\tgit -C pm-positional branch one one-commit &&\n     -+\tgit -C pm-positional branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-positional branch two base &&\n     -+\tgit -C pm-positional branch --set-upstream-to=origin/main two &&\n     -+\tgit -C pm-positional checkout --detach &&\n     -+\n     -+\tgit -C pm-positional branch --delete-merged origin/next origin/main &&\n     -+\n     -+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&\n     -+\ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n     -+'\n      +\n       test_done\n 6:  72aaca0666 ! 6:  a27d2724a2 branch: add branch.<name>.deleteMerged opt-out\n     @@ Documentation/git-branch.adoc: A branch is not deleted when:\n       A branch whose work has not yet been merged into its upstream is\n      \n       ## builtin/branch.c ##\n     +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     + \tstruct ref_filter filter = REF_FILTER_INIT;\n     + \tstruct ref_array candidates = { 0 };\n     + \tstruct strvec deletable = STRVEC_INIT;\n     ++\tstruct strbuf key = STRBUF_INIT;\n     ++\tbool quiet = flags & DELETE_BRANCH_QUIET;\n     + \tint i, ret = 0;\n     + \n     + \tif (!argc)\n      @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tconst char *short_name;\n       \t\tstruct branch *branch;\n       \t\tconst char *upstream, *push;\n     -+\t\tstruct strbuf key = STRBUF_INIT;\n      +\t\tint opt_out;\n       \n       \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tif (!push || !strcmp(push, upstream))\n       \t\t\tcontinue;\n       \n     ++\t\tstrbuf_reset(&key);\n      +\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n      +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n      +\t\t    !opt_out) {\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n      +\t\t\t\tfprintf(stderr,\n      +\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n     -+\t\t\tstrbuf_release(&key);\n      +\t\t\tcontinue;\n      +\t\t}\n     -+\t\tstrbuf_release(&key);\n      +\n       \t\tstrvec_push(&deletable, short_name);\n       \t}\n       \n     +@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     + \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     + \t\t\t\t      flags);\n     + \n     ++\tstrbuf_release(&key);\n     + \tstrvec_clear(&deletable);\n     + \tref_array_clear(&candidates);\n     + \tref_filter_clear(&filter);\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged takes positional <branch> arguments' '\n     - \ttest_must_fail git -C pm-positional rev-parse --verify refs/heads/two\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires at least one <branch>' '\n     + \ttest_grep \"requires at least one <branch>\" err\n       '\n       \n      +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n     -+\ttest_when_finished \"rm -rf pm-optout\" &&\n     -+\tgit clone pm-upstream pm-optout &&\n     -+\tgit -C pm-optout remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-optout remote.pushDefault fork &&\n     -+\ttest_config -C pm-optout push.default current &&\n     -+\tgit -C pm-optout branch one one-commit &&\n     -+\tgit -C pm-optout branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-optout branch two two-commit &&\n     -+\tgit -C pm-optout branch --set-upstream-to=origin/next two &&\n     -+\ttest_config -C pm-optout branch.one.deleteMerged false &&\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tmerged_branch deleted origin/next &&\n     ++\tmerged_branch kept origin/next &&\n     ++\tgit -C repo config branch.kept.deleteMerged false &&\n     ++\tgit -C repo checkout --detach &&\n      +\n     -+\tgit -C pm-optout branch --delete-merged \"origin/*\" 2>err &&\n     ++\tgit -C repo branch --delete-merged origin/next 2>err &&\n      +\n     -+\tgit -C pm-optout rev-parse --verify refs/heads/one &&\n     -+\ttest_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&\n     -+\ttest_grep \"Skipping .one.\" err\n     ++\ttest_grep \"Skipping .kept.\" err &&\n     ++\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n     ++\tgit -C repo rev-parse --verify refs/heads/kept\n      +'\n      +\n     -+test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n     -+\ttest_when_finished \"rm -rf pm-optout-d\" &&\n     -+\tgit clone pm-upstream pm-optout-d &&\n     -+\tgit -C pm-optout-d branch one one-commit &&\n     -+\tgit -C pm-optout-d branch --set-upstream-to=origin/next one &&\n     -+\ttest_config -C pm-optout-d branch.one.deleteMerged false &&\n     ++test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tmerged_branch kept origin/next &&\n     ++\tgit -C repo config branch.kept.deleteMerged false &&\n     ++\tgit -C repo checkout --detach &&\n      +\n     -+\tgit -C pm-optout-d branch -d one &&\n     -+\ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n     ++\tgit -C repo branch -d kept &&\n     ++\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n      +'\n      +\n       test_done\n 7:  7b2b01b988 ! 7:  6d5c52353e branch: add --dry-run for --delete-merged\n     @@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n     - }\n     - \n     - static int delete_merged_branches(int argc, const char **argv,\n     --\t\t\t\t int quiet)\n     -+\t\t\t\t int quiet, int dry_run)\n     - {\n     - \tstruct ref_store *refs = get_main_ref_store(the_repository);\n     - \tstruct ref_filter filter = REF_FILTER_INIT;\n     -@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     - \t\t\t\t      FILTER_REFS_BRANCHES,\n     - \t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n     - \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     --\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n     -+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0) |\n     -+\t\t\t\t      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n     - \n     - \tstrvec_clear(&deletable);\n     - \tref_array_clear(&candidates);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tif (!submodule_propagate_branches)\n       \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n       \t} else if (delete_merged) {\n     --\t\tret = delete_merged_branches(argc, argv, quiet);\n     -+\t\tret = delete_merged_branches(argc, argv, quiet, dry_run);\n     + \t\tret = delete_merged_branches(argc, argv,\n     +-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n     ++\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n     ++\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n       \t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a deleteMerged=false branch' '\n     - \ttest_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes merged branches and spares the rest\n     + \t) &&\n     + \tsha=$(git -C repo rev-parse --short merged) &&\n     + \n     +-\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     ++\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n     ++\techo \"Would delete branch merged (was $sha).\" >expect &&\n     ++\ttest_cmp expect actual &&\n     ++\tgit -C repo rev-parse --verify refs/heads/merged &&\n     + \n     ++\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     + \techo \"Deleted branch merged (was $sha).\" >expect &&\n     + \ttest_cmp expect actual &&\n     + \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     +@@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n     + \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n       '\n       \n     -+test_expect_success '--delete-merged --dry-run lists but does not delete' '\n     -+\ttest_when_finished \"rm -rf pm-dry\" &&\n     -+\tgit clone pm-upstream pm-dry &&\n     -+\tgit -C pm-dry remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-dry remote.pushDefault fork &&\n     -+\ttest_config -C pm-dry push.default current &&\n     -+\tgit -C pm-dry branch one one-commit &&\n     -+\tgit -C pm-dry branch --set-upstream-to=origin/next one &&\n     -+\tgit -C pm-dry branch two two-commit &&\n     -+\tgit -C pm-dry branch --set-upstream-to=origin/next two &&\n     -+\n     -+\tgit -C pm-dry branch --dry-run --delete-merged \"origin/*\" >actual &&\n     -+\ttest_grep \"Would delete branch one \" actual &&\n     -+\ttest_grep \"Would delete branch two \" actual &&\n     -+\n     -+\tgit -C pm-dry rev-parse --verify refs/heads/one &&\n     -+\tgit -C pm-dry rev-parse --verify refs/heads/two\n     -+'\n     -+\n     -+test_expect_success '--delete-merged --dry-run only lists branches the live run would delete' '\n     -+\ttest_when_finished \"rm -rf pm-dry-mixed\" &&\n     -+\tgit clone pm-upstream pm-dry-mixed &&\n     -+\tgit -C pm-dry-mixed remote add fork ../pm-fork &&\n     -+\ttest_config -C pm-dry-mixed remote.pushDefault fork &&\n     -+\ttest_config -C pm-dry-mixed push.default current &&\n     -+\tgit -C pm-dry-mixed checkout -b wip origin/next &&\n     -+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&\n     -+\ttest_commit -C pm-dry-mixed local-only &&\n     -+\tgit -C pm-dry-mixed checkout - &&\n     -+\tgit -C pm-dry-mixed branch merged one-commit &&\n     -+\tgit -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&\n     -+\n     -+\tgit -C pm-dry-mixed branch --dry-run --delete-merged \"origin/*\" >out &&\n     -+\ttest_grep \"Would delete branch merged\" out &&\n     -+\ttest_grep ! \"Would delete branch wip\" out &&\n     -+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/wip &&\n     -+\tgit -C pm-dry-mixed rev-parse --verify refs/heads/merged\n     -+'\n     -+\n      +test_expect_success '--dry-run without --delete-merged is rejected' '\n      +\ttest_must_fail git -C forked branch --dry-run 2>err &&\n      +\ttest_grep \"requires --delete-merged\" err\n\n-- \ngitgitgadget\n"},{"id":"545893","messageId":"1f6a75826585fd05250da10650ddda0545494872.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:23Z","receivedAt":"2026-06-18T19:25:33Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 ++++-\n ref-filter.c                  |  70 +++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 122 ++++++++++++++++++++++++++++++++++\n 5 files changed, 229 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..1ddd5a3f6d 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..3104c555f6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit remote set-head origin one &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n+\t\tgit checkout --detach\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout --detach\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545894","messageId":"4f8af602ba67a31949fdf8eda31ca221138ae585.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:24Z","receivedAt":"2026-06-18T19:25:34Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() and check_branch_commit() take a pair of int\nbooleans (force and quiet) that the next commits would grow further.\nReplace them with a single \"unsigned int flags\" argument and an\nenum, splitting the bits back into named bool locals so the body\nkeeps reading the same named values.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 36 ++++++++++++++++++++++++------------\n 1 file changed, 24 insertions(+), 12 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..a9be980aef 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tbool force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tbool force;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n+\tforce = flags & DELETE_BRANCH_FORCE;\n+\n \tif (!force)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +882,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"545895","messageId":"efc891c25556aafef340f1d626db0a50f3acdba5.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:25Z","receivedAt":"2026-06-18T19:25:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 17 ++++++++++++-----\n 1 file changed, 12 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a9be980aef..4c569d056a 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       int kinds, unsigned int flags)\n {\n \tbool force = flags & DELETE_BRANCH_FORCE;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!skip_unmerged) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint remote_branch = 0;\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!skip_unmerged)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"545896","messageId":"b1ecd38fe37119ed2b067fb9cf0ec936fcc23081.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:26Z","receivedAt":"2026-06-18T19:25:37Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--delete-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 26 ++++++++++++++++++++------\n 1 file changed, 20 insertions(+), 6 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 4c569d056a..1d3f28e4cb 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,8 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -241,6 +246,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n+\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -268,7 +275,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tforce = flags & DELETE_BRANCH_FORCE;\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n@@ -339,13 +346,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n-- \ngitgitgadget\n\n"},{"id":"545897","messageId":"998fb6a68cef85a71420446c62ff7cdc909913d5.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:27Z","receivedAt":"2026-06-18T19:25:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --delete-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream. The work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nThree kinds of branches are not deleted:\n\n  * any branch checked out in any worktree\n  * any branch whose upstream remote-tracking branch no longer\n    exists, since a missing upstream is not by itself a sign of\n    integration\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} is the same as <branch>@{upstream}), such as\n    a local \"main\" that tracks and pushes to \"origin/main\". Right\n    after a pull it just looks \"fully merged\", so it is kept. Only\n    branches that push somewhere other than their upstream,\n    typically topics in a fork workflow, are candidates.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  24 ++++++++\n builtin/branch.c              |  67 +++++++++++++++++++++-\n t/t3200-branch.sh             | 105 ++++++++++++++++++++++++++++++++++\n 3 files changed, 194 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..f82cfa36d0 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--delete-merged origin/main 'feature*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its upstream remote-tracking branch no longer exists,\n+* it is checked out in any worktree, or\n+* its push destination (`<branch>@{push}`) equals its upstream\n+  (`<branch>@{upstream}`), so it cannot be distinguished from a\n+  branch that just looks \"fully merged\" right after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1d3f28e4cb..e7e4f1d27f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n \tNULL\n };\n \n@@ -714,6 +715,60 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+static int delete_merged_branches(int argc, const char **argv,\n+\t\t\t\t unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strvec deletable = STRVEC_INIT;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch_get_push(branch, NULL);\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\n+\t\tstrvec_push(&deletable, short_name);\n+\t}\n+\n+\tif (deletable.nr)\n+\t\tret = delete_branches(deletable.nr, deletable.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -755,6 +810,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint delete_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -808,6 +864,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -855,7 +913,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -865,7 +924,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -907,6 +966,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged) {\n+\t\tret = delete_merged_branches(argc, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3104c555f6..609a67bb5a 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1839,4 +1839,109 @@ test_expect_success '--forked narrows a <pattern> argument' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" \"$2\" &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\" &&\n+\t\tgit fetch origin &&\n+\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n+\t)\n+}\n+\n+test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch merged origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit branch --set-upstream-to=origin/next unmerged &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout --detach\n+\t) &&\n+\tsha=$(git -C repo rev-parse --short merged) &&\n+\n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\n+\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tmain\n+\ttracks-other\n+\tunmerged\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch on-next origin/next &&\n+\tmerged_branch checked-out origin/next &&\n+\tmerged_branch upstream-gone origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b mainline main &&\n+\t\tgit checkout -b on-local mainline &&\n+\t\tgit branch --set-upstream-to=mainline on-local &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit branch --set-upstream-to=origin/main main &&\n+\t\tgit config branch.main.pushRemote origin &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout checked-out\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next mainline &&\n+\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tchecked-out\n+\tmain\n+\tmainline\n+\ttracks-other\n+\tupstream-gone\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545898","messageId":"a27d2724a2e394e1e985baf08f4b9ba5f27b7947.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:28Z","receivedAt":"2026-06-18T19:25:41Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 15 +++++++++++++++\n t/t3200-branch.sh                | 26 ++++++++++++++++++++++++++\n 4 files changed, 51 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex f82cfa36d0..91700f2e8a 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -215,10 +215,11 @@ A branch is not deleted when:\n +\n --\n * its upstream remote-tracking branch no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * its push destination (`<branch>@{push}`) equals its upstream\n   (`<branch>@{upstream}`), so it cannot be distinguished from a\n-  branch that just looks \"fully merged\" right after a pull.\n+  branch that just looks \"fully merged\" right after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex e7e4f1d27f..942e2297c8 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -722,6 +722,8 @@ static int delete_merged_branches(int argc, const char **argv,\n \tstruct ref_filter filter = REF_FILTER_INIT;\n \tstruct ref_array candidates = { 0 };\n \tstruct strvec deletable = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tint i, ret = 0;\n \n \tif (!argc)\n@@ -739,6 +741,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n@@ -753,6 +756,17 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tif (!push || !strcmp(push, upstream))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrvec_push(&deletable, short_name);\n \t}\n \n@@ -763,6 +777,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&deletable);\n \tref_array_clear(&candidates);\n \tref_filter_clear(&filter);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 609a67bb5a..09cecfaff5 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1944,4 +1944,30 @@ test_expect_success '--delete-merged requires at least one <branch>' '\n \ttest_grep \"requires at least one <branch>\" err\n '\n \n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch deleted origin/next &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch --delete-merged origin/next 2>err &&\n+\n+\ttest_grep \"Skipping .kept.\" err &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n+\tgit -C repo rev-parse --verify refs/heads/kept\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch -d kept &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"545899","messageId":"6d5c52353e0bec2cba058f37b267a00a7b4f07ec.1781810729.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v16 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-18T19:25:29Z","receivedAt":"2026-06-18T19:25:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --delete-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              |  9 ++++++++-\n t/t3200-branch.sh             | 11 ++++++++++-\n 3 files changed, 25 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 91700f2e8a..09063d74f2 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --delete-merged <branch>...\n+git branch [--dry-run] --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -226,6 +226,12 @@ A branch whose work has not yet been merged into its upstream is\n silently skipped. Delete it with `git branch -D` if you want to\n remove it anyway.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 942e2297c8..f67d0949fe 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -826,6 +826,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint delete_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -881,6 +882,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -943,6 +946,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -983,7 +989,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged) {\n \t\tret = delete_merged_branches(argc, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 09cecfaff5..4a71845c76 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest\n \t) &&\n \tsha=$(git -C repo rev-parse --short merged) &&\n \n-\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n+\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo rev-parse --verify refs/heads/merged &&\n \n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n \techo \"Deleted branch merged (was $sha).\" >expect &&\n \ttest_cmp expect actual &&\n \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n@@ -1970,4 +1974,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"545950","messageId":"42ffcb36-7fff-4948-9b8d-2c54eb626e66@gmail.com","threadId":"65574","inReplyTo":"xmqqcxxnsufl.fsf@gitster.g","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-19T13:13:27Z","receivedAt":"2026-06-19T13:13:34Z","isPatch":true,"body":"On 18/06/2026 17:08, Junio C Hamano wrote:\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n> \n>> One thing I've just thought of related to this patch is whether we want\n>> to protect branches that are the upstreams of branches that are not\n>> slated for deletion. With stacked branches it is possible that a branch\n>> has been merged but has other branches stacked on top of it that have\n>> not been merged.\n> \n> An interesting point.  We do have \"this topic is built on the result\n> of merging these other topics into main\" and I expect the practice\n> is wide spread.  These base topics may graduate first, but other\n> topics may still be updated.\n> \n> But when you rewrite these other topics, wouldn't you leave their\n> bases untouched?  IOW, a new iteration (i.e. \"rebase -i\") would\n> reuse the base that was used in an earlier iteration, i.e. the\n> result of an earlier merge of the other topics, some of which might\n> have been pruned since then, into an older 'main', so it is OK to\n> lose these other topics once they have graduated, simply because you\n> wouldn't be recreating the merge that you used as the base of this\n> remaining topic, no?\n> \n> Or am I missing something?\n\nI was thinking that if I have feature1 with upstream origin/master and \nfeature2 with upstream feautre1, then once feature1 is merged I'd still \nlike \"git log @{u}..\" and \"git rebase\" without an explicit upstream to \nwork when feature2 is checked out. If \"git branch --prune-merged \norigin/master\" deletes feautre1 then those commands stop working. Maybe \nit would be sensible to update feature2's upstream once feature1 is \nmerged (which I think is what you're saying above) but do we really want \nto force the user to do that by deleting feature1?\n\nThanks\n\nPhillip\n\n"},{"id":"545980","messageId":"xmqqh5mymt8i.fsf@gitster.g","threadId":"65574","inReplyTo":"42ffcb36-7fff-4948-9b8d-2c54eb626e66@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-19T15:42:53Z","receivedAt":"2026-06-19T15:42:56Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> I was thinking that if I have feature1 with upstream origin/master and \n> feature2 with upstream feautre1, then once feature1 is merged I'd still \n> like \"git log @{u}..\" and \"git rebase\" without an explicit upstream to \n> work when feature2 is checked out. If \"git branch --prune-merged \n> origin/master\" deletes feautre1 then those commands stop working. Maybe \n> it would be sensible to update feature2's upstream once feature1 is \n> merged (which I think is what you're saying above) but do we really want \n> to force the user to do that by deleting feature1?\n\nAhh, reference with @{upstream}.  Yeah, that _does_ make sense.\n"},{"id":"545985","messageId":"xmqq33yimsdp.fsf@gitster.g","threadId":"65574","inReplyTo":"xmqqh5mymt8i.fsf@gitster.g","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-19T16:01:22Z","receivedAt":"2026-06-19T16:01:24Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Phillip Wood <phillip.wood123@gmail.com> writes:\n>\n>> I was thinking that if I have feature1 with upstream origin/master and \n>> feature2 with upstream feautre1, then once feature1 is merged I'd still \n>> like \"git log @{u}..\" and \"git rebase\" without an explicit upstream to \n>> work when feature2 is checked out. If \"git branch --prune-merged \n>> origin/master\" deletes feautre1 then those commands stop working. Maybe \n>> it would be sensible to update feature2's upstream once feature1 is \n>> merged (which I think is what you're saying above) but do we really want \n>> to force the user to do that by deleting feature1?\n>\n> Ahh, reference with @{upstream}.  Yeah, that _does_ make sense.\n\nOops, hit [Send] too soon.\n\nThis reminds me of the discussion on a separate thread on \"history\nsquash\" and \"history drop\", that squashes a segment of history\n\n    o---A---B---C\n\ninto a single commit (i.e., \"We want to remove A and B and keep C\" or\n\"We want to squash A, B, and C into a single commit\")\n\n    o---X\n\nIt is natural to consider \"X\" in the new history a counterpart of\n\"C\" in the original history, but what should happen to refs that\nused to point at A and B?  Sometimes it may make sense to drop the\nrefs that point into commits that disappear in the final history,\nsometimes it may make sense to move them to point at the surviving\ncommit, sometimes it may make sense to leave them as-is (i.e., refs\nstill point at A and B in the old history, preventing them from\ngetting gc'ed).\n\nAnd the safest behaviour would be to ask the user, i.e., notice that\nwe would get into this iffy situation and abort, asking the user\nwhat to do.\n\nNow that safest behaviour in the other topic translates to\n\n - Notice that a branch that is deleted (because it itself is\n   merged) is still depended upon by being @{upstream} of somebody\n   else, and when it happens, fail the operation (i.e., do not\n   delete the branch).\n\nWhat are the viable choices we can offer to the user in such a\nsituation?  I think there are a few viable choices.\n\n - Make the dependent branch no longer depend on anything.  Asking\n   feature2@{upstream} would fail.\n\n - Move the @{upstream} of feature2 to the branch that \"merged\"\n   feature1 and caused its removal.  Asking feature2@{upstream}\n   would answer origin/master, which feature1 was removed after\n   getting merged.\n\nThere may be others.  And there may be relationship similar to\nfeature1 vs feature2 that is not @{upstream} but something else that\nmakes a branch still \"depend on\" the other branch getting deleted.\n\nDo we also need the same safety around \"git branch -d feature1\" by\nthe way?  The \"-d\" option with safety checks the same \"is feature1\nalready merged (to its upstream)?\" condition, so it can protect the\nfeature2 branch the same way, by saying either \"oops, you cannot\ndelete feature1 because you still have other branches like feature2\nthat depend on it\", or \"ok, featur2 used to depend on feature1, but\nbecause we are deleting feature1 based on it being in origin/master,\nwe will make feature2 depend on origin/master from now on\".\n\nThanks.\n\n\n"},{"id":"546015","messageId":"CAHwyqnWt59h2HO5EJbFswYr7QEA7oNZKdBt_vTk5axNbWFZbpA@mail.gmail.com","threadId":"65574","inReplyTo":"xmqq33yimsdp.fsf@gitster.g","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-20T09:04:02Z","receivedAt":"2026-06-20T09:04:41Z","isPatch":true,"body":">  - Move the @{upstream} of feature2 to the branch that \"merged\"\n>    feature1 and caused its removal.  Asking feature2@{upstream}\n>    would answer origin/master, which feature1 was removed after\n>    getting merged.\n\nI think this is a strong option.\n\nAs a side note: I was annoyed before when GitHub didn't re-assign base\nautomatically when doing stacked PR's, so merging in the first branch\ncaused developers to  merge in the second PR into essentially a dead\nfeature branch instead of master, if they forgot to manually change\nit. But I think GitHub has fixed this now so the second PR gets its\nbase changed to default branch.\n\nTwo caveats:\n\n- How to handle recursion: b1 has b2 as upstream and b2 has b3 as\nupstream, and both b2 and b3 have been merged? Not good if it's just\nluck which order the branches get walked, but also we don't want to\nhave to do many passes, two passes is not even guaranteed to be\nenough.\n- What about when b3 has itself as upstream? I guess then we can just\nremove the upstream of b2. Overall, I don't think it's a huge problem\nwhen a branch gets no upstream, so maybe just warn about it.\n\n\nHarald\n"},{"id":"546084","messageId":"CAHwyqnVce7NKft9AEyCUnR=S_y1ygiXjhf-qmJqmi-tuUXcw=g@mail.gmail.com","threadId":"65574","inReplyTo":"CAHwyqnWt59h2HO5EJbFswYr7QEA7oNZKdBt_vTk5axNbWFZbpA@mail.gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-21T18:46:32Z","receivedAt":"2026-06-21T18:47:10Z","isPatch":true,"body":"Looking into this more and attempting to implement the logic for\nre-assigning the upstream, it becomes quite a lot of code.\n\nMaybe an easier way forward now is to avoid deleting these cases. We\ncan always add the re-assigning logic later on without breaking\nbackward compatibility.\n\n\nHarald\n"},{"id":"546108","messageId":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v16.git.git.1781810729.gitgitgadget@gmail.com","subject":"[PATCH v17 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:41Z","receivedAt":"2026-06-22T07:29:51Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  47 ++++-\n builtin/branch.c                 | 247 ++++++++++++++++++++++---\n ref-filter.c                     |  70 +++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 308 +++++++++++++++++++++++++++++++\n 6 files changed, 661 insertions(+), 28 deletions(-)\n\n\nbase-commit: 8d96f09e9245ddf80c1981476fcbac8c4bb4125f\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v17\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v17\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v16:\n\n 1:  1f6a758265 = 1:  d8cc17bd7f branch: add --forked filter for --list mode\n 2:  4f8af602ba = 2:  d14b0403f0 branch: convert delete_branches() to a flags argument\n 3:  efc891c255 = 3:  ef2719dac3 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  b1ecd38fe3 ! 4:  80518f5d11 branch: prepare delete_branches for a bulk caller\n     @@ builtin/branch.c: enum delete_branch_flags {\n       \tDELETE_BRANCH_QUIET = (1 << 1),\n       \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n      +\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n     -+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n       };\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n       \tbool force;\n       \tbool quiet = flags & DELETE_BRANCH_QUIET;\n       \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     -+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n      +\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n       \tstruct strbuf bname = STRBUF_INIT;\n       \tenum interpret_branch_kind allowed_interpret;\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n       \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n       \n       \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \t\tfree(target);\n     - \t}\n     - \n     --\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     -+\tif (!dry_run &&\n     -+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     - \t\tret = 1;\n     - \n     - \tfor_each_string_list_item(item, &refs_to_delete) {\n     - \t\tchar *describe_ref = item->util;\n     - \t\tchar *name = item->string;\n     --\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     -+\t\tif (dry_run) {\n     -+\t\t\tif (!quiet)\n     -+\t\t\t\tprintf(remote_branch\n     -+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n     -+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n     -+\t\t\t\t\tname + branch_name_pos, describe_ref);\n     -+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     - \t\t\tchar *refname = name + branch_name_pos;\n     - \t\t\tif (!quiet)\n     - \t\t\t\tprintf(remote_branch\n 5:  998fb6a68c ! 5:  46da7c8140 branch: add --delete-merged <branch>\n     @@ Commit message\n          A branch whose work is not yet merged into its upstream is silently\n          skipped, so one unmerged topic does not abort the whole sweep.\n      \n     +    A branch that another, surviving branch tracks as its upstream is\n     +    also kept, so a branch is never deleted out from under one stacked\n     +    on top of it. Sparing such a base can in turn protect its own\n     +    upstream, so the check repeats until the set stops changing.\n     +\n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Documentation/git-branch.adoc ##\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n      +A branch whose work has not yet been merged into its upstream is\n      +silently skipped. Delete it with `git branch -D` if you want to\n      +remove it anyway.\n     +++\n     ++A branch that another, surviving branch still tracks as its upstream\n     ++is kept, so a branch is never deleted out from under one stacked on\n     ++top of it.\n      +\n       `-v`::\n       `-vv`::\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     +@@\n     + #include \"branch.h\"\n     + #include \"path.h\"\n     + #include \"string-list.h\"\n     ++#include \"strmap.h\"\n     + #include \"column.h\"\n     + #include \"utf8.h\"\n     + #include \"ref-filter.h\"\n      @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n       \treturn 0;\n       }\n       \n     ++static int collect_upstream(const struct reference *ref, void *cb_data)\n     ++{\n     ++\tstruct string_list *upstreams = cb_data;\n     ++\tstruct branch *branch = branch_get(ref->name);\n     ++\tconst char *upstream = branch_get_upstream(branch, NULL);\n     ++\n     ++\tstring_list_append(upstreams, ref->name)->util =\n     ++\t\txstrdup_or_null(upstream);\n     ++\treturn 0;\n     ++}\n     ++\n     ++/*\n     ++ * Keep any branch that another, surviving branch tracks as its\n     ++ * upstream, so we never delete a branch out from under one stacked on\n     ++ * top of it.  Sparing a branch makes it a survivor whose own upstream\n     ++ * then needs the same protection, so repeat until nothing changes.\n     ++ */\n     ++static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n     ++{\n     ++\tstruct string_list upstreams = STRING_LIST_INIT_DUP;\n     ++\tstruct string_list_item *item;\n     ++\tbool spared;\n     ++\n     ++\trefs_for_each_branch_ref(refs, collect_upstream, &upstreams);\n     ++\tdo {\n     ++\t\tspared = false;\n     ++\t\tfor_each_string_list_item(item, &upstreams) {\n     ++\t\t\tconst char *up = item->util, *up_short;\n     ++\n     ++\t\t\tif (!up || strset_contains(deletable, item->string))\n     ++\t\t\t\tcontinue;\n     ++\t\t\tif (!skip_prefix(up, \"refs/heads/\", &up_short) ||\n     ++\t\t\t    !strset_contains(deletable, up_short))\n     ++\t\t\t\tcontinue;\n     ++\n     ++\t\t\tstrset_remove(deletable, up_short);\n     ++\t\t\tspared = true;\n     ++\t\t}\n     ++\t} while (spared);\n     ++\n     ++\tstring_list_clear(&upstreams, 1);\n     ++}\n     ++\n      +static int delete_merged_branches(int argc, const char **argv,\n      +\t\t\t\t unsigned int flags)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n      +\tstruct ref_array candidates = { 0 };\n     -+\tstruct strvec deletable = STRVEC_INIT;\n     ++\tstruct strset deletable = STRSET_INIT;\n     ++\tstruct strvec to_delete = STRVEC_INIT;\n      +\tint i, ret = 0;\n      +\n      +\tif (!argc)\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\tpush = branch_get_push(branch, NULL);\n      +\t\tif (!push || !strcmp(push, upstream))\n      +\t\t\tcontinue;\n     ++\t\tif (check_branch_commit(short_name, short_name,\n     ++\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n     ++\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n     ++\t\t\tcontinue;\n      +\n     -+\t\tstrvec_push(&deletable, short_name);\n     ++\t\tstrset_add(&deletable, short_name);\n      +\t}\n      +\n     -+\tif (deletable.nr)\n     -+\t\tret = delete_branches(deletable.nr, deletable.v,\n     ++\tspare_stacked_bases(refs, &deletable);\n     ++\n     ++\tfor (i = 0; i < candidates.nr; i++) {\n     ++\t\tconst char *short_name;\n     ++\n     ++\t\tif (skip_prefix(candidates.items[i]->refname, \"refs/heads/\",\n     ++\t\t\t\t&short_name) &&\n     ++\t\t    strset_contains(&deletable, short_name))\n     ++\t\t\tstrvec_push(&to_delete, short_name);\n     ++\t}\n     ++\n     ++\tif (to_delete.nr)\n     ++\t\tret = delete_branches(to_delete.nr, to_delete.v,\n      +\t\t\t\t      FILTER_REFS_BRANCHES,\n      +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n      +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n      +\t\t\t\t      flags);\n      +\n     -+\tstrvec_clear(&deletable);\n     ++\tstrvec_clear(&to_delete);\n     ++\tstrset_clear(&deletable);\n      +\tref_array_clear(&candidates);\n      +\tref_filter_clear(&filter);\n      +\treturn ret;\n     @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n      +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n      +\ttest_grep \"requires at least one <branch>\" err\n      +'\n     ++\n     ++test_expect_success '--delete-merged keeps a branch that is an upstream' '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tmerged_branch feature origin/next &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b topic feature &&\n     ++\t\tgit commit --allow-empty -m \"topic work\" &&\n     ++\t\tgit branch --set-upstream-to=feature topic &&\n     ++\t\tgit checkout --detach\n     ++\t) &&\n     ++\n     ++\tgit -C repo branch --delete-merged origin/next 2>err &&\n     ++\n     ++\ttest_must_be_empty err &&\n     ++\tgit -C repo rev-parse --verify refs/heads/feature &&\n     ++\tgit -C repo rev-parse --verify refs/heads/topic\n     ++'\n     ++\n     ++test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit branch b3 origin/next &&\n     ++\t\tgit branch --set-upstream-to=origin/next b3 &&\n     ++\t\tgit branch b2 origin/next &&\n     ++\t\tgit branch --set-upstream-to=b3 b2 &&\n     ++\t\tgit checkout -b b1 b2 &&\n     ++\t\tgit commit --allow-empty -m \"b1 work\" &&\n     ++\t\tgit branch --set-upstream-to=b2 b1 &&\n     ++\t\tgit checkout --detach\n     ++\t) &&\n     ++\n     ++\tgit -C repo branch --delete-merged origin/next &&\n     ++\n     ++\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     ++\tcat >expect <<-\\EOF &&\n     ++\tb1\n     ++\tb2\n     ++\tb3\n     ++\tmain\n     ++\tEOF\n     ++\ttest_cmp expect actual\n     ++'\n      +\n       test_done\n 6:  a27d2724a2 ! 6:  27903fbb1d branch: add branch.<name>.deleteMerged opt-out\n     @@ Documentation/git-branch.adoc: A branch is not deleted when:\n      \n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     - \tstruct ref_filter filter = REF_FILTER_INIT;\n       \tstruct ref_array candidates = { 0 };\n     - \tstruct strvec deletable = STRVEC_INIT;\n     + \tstruct strset deletable = STRSET_INIT;\n     + \tstruct strvec to_delete = STRVEC_INIT;\n      +\tstruct strbuf key = STRBUF_INIT;\n      +\tbool quiet = flags & DELETE_BRANCH_QUIET;\n       \tint i, ret = 0;\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n       \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n      @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     - \t\tif (!push || !strcmp(push, upstream))\n     + \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n       \t\t\tcontinue;\n       \n      +\t\tstrbuf_reset(&key);\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n      +\t\t\tcontinue;\n      +\t\t}\n      +\n     - \t\tstrvec_push(&deletable, short_name);\n     + \t\tstrset_add(&deletable, short_name);\n       \t}\n       \n      @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\t\t\t      flags);\n       \n      +\tstrbuf_release(&key);\n     - \tstrvec_clear(&deletable);\n     + \tstrvec_clear(&to_delete);\n     + \tstrset_clear(&deletable);\n       \tref_array_clear(&candidates);\n     - \tref_filter_clear(&filter);\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires at least one <branch>' '\n     - \ttest_grep \"requires at least one <branch>\" err\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch\n     + \ttest_cmp expect actual\n       '\n       \n      +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n 7:  6d5c52353e ! 7:  49c1bcf1fb branch: add --dry-run for --delete-merged\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is\n     - silently skipped. Delete it with `git branch -D` if you want to\n     - remove it anyway.\n     +@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream\n     + is kept, so a branch is never deleted out from under one stacked on\n     + top of it.\n       \n      +`--dry-run`::\n      +\tWith `--delete-merged`, print which branches would be\n     @@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into\n       `--verbose`::\n      \n       ## builtin/branch.c ##\n     +@@ builtin/branch.c: enum delete_branch_flags {\n     + \tDELETE_BRANCH_QUIET = (1 << 1),\n     + \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n     + \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n     ++\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n     + };\n     + \n     + static int check_branch_commit(const char *branchname, const char *refname,\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     + \tbool quiet = flags & DELETE_BRANCH_QUIET;\n     + \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     + \tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n     ++\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n     + \tstruct strbuf bname = STRBUF_INIT;\n     + \tenum interpret_branch_kind allowed_interpret;\n     + \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     + \t\tfree(target);\n     + \t}\n     + \n     +-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     ++\tif (!dry_run &&\n     ++\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     + \t\tret = 1;\n     + \n     + \tfor_each_string_list_item(item, &refs_to_delete) {\n     + \t\tchar *describe_ref = item->util;\n     + \t\tchar *name = item->string;\n     +-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     ++\t\tif (dry_run) {\n     ++\t\t\tif (!quiet)\n     ++\t\t\t\tprintf(remote_branch\n     ++\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n     ++\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n     ++\t\t\t\t\tname + branch_name_pos, describe_ref);\n     ++\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     + \t\t\tchar *refname = name + branch_name_pos;\n     + \t\t\tif (!quiet)\n     + \t\t\t\tprintf(remote_branch\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n\n-- \ngitgitgadget\n"},{"id":"546109","messageId":"d8cc17bd7fd28f7d4a541b734e9c61d0c8138043.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:42Z","receivedAt":"2026-06-22T07:29:52Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 ++++-\n ref-filter.c                  |  70 +++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 122 ++++++++++++++++++++++++++++++++++\n 5 files changed, 229 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 1da4c0e60d..1ddd5a3f6d 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..3104c555f6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit remote set-head origin one &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n+\t\tgit checkout --detach\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout --detach\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546110","messageId":"d14b0403f08bbb066905cebab4471bc480f73dbb.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:43Z","receivedAt":"2026-06-22T07:29:54Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() and check_branch_commit() take a pair of int\nbooleans (force and quiet) that the next commits would grow further.\nReplace them with a single \"unsigned int flags\" argument and an\nenum, splitting the bits back into named bool locals so the body\nkeeps reading the same named values.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 36 ++++++++++++++++++++++++------------\n 1 file changed, 24 insertions(+), 12 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..a9be980aef 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tbool force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tbool force;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n+\tforce = flags & DELETE_BRANCH_FORCE;\n+\n \tif (!force)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +882,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"546112","messageId":"ef2719dac304ad7d722ef8d007447ce8c5a3a3c6.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:44Z","receivedAt":"2026-06-22T07:29:55Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 17 ++++++++++++-----\n 1 file changed, 12 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a9be980aef..4c569d056a 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       int kinds, unsigned int flags)\n {\n \tbool force = flags & DELETE_BRANCH_FORCE;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!skip_unmerged) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint remote_branch = 0;\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!skip_unmerged)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"546111","messageId":"80518f5d11bf0594aee907870e8f72d90f695aea.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:45Z","receivedAt":"2026-06-22T07:29:56Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--delete-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 4c569d056a..01c1f64c73 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -241,6 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n+\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -268,7 +273,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tforce = flags & DELETE_BRANCH_FORCE;\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"546113","messageId":"46da7c814056ddbc23accf19a61d0451b949127e.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:46Z","receivedAt":"2026-06-22T07:30:00Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --delete-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream. The work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nThree kinds of branches are not deleted:\n\n  * any branch checked out in any worktree\n  * any branch whose upstream remote-tracking branch no longer\n    exists, since a missing upstream is not by itself a sign of\n    integration\n  * any branch whose push destination equals its upstream\n    (<branch>@{push} is the same as <branch>@{upstream}), such as\n    a local \"main\" that tracks and pushes to \"origin/main\". Right\n    after a pull it just looks \"fully merged\", so it is kept. Only\n    branches that push somewhere other than their upstream,\n    typically topics in a fork workflow, are candidates.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that another, surviving branch tracks as its upstream is\nalso kept, so a branch is never deleted out from under one stacked\non top of it. Sparing such a base can in turn protect its own\nupstream, so the check repeats until the set stops changing.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  28 +++++++\n builtin/branch.c              | 128 +++++++++++++++++++++++++++-\n t/t3200-branch.sh             | 151 ++++++++++++++++++++++++++++++++++\n 3 files changed, 305 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..56ff889447 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,33 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--delete-merged origin/main 'feature*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its upstream remote-tracking branch no longer exists,\n+* it is checked out in any worktree, or\n+* its push destination (`<branch>@{push}`) equals its upstream\n+  (`<branch>@{upstream}`), so it cannot be distinguished from a\n+  branch that just looks \"fully merged\" right after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that another, surviving branch still tracks as its upstream\n+is kept, so a branch is never deleted out from under one stacked on\n+top of it.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 01c1f64c73..35fd3e9efc 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n \tNULL\n };\n \n@@ -705,6 +707,120 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+static int collect_upstream(const struct reference *ref, void *cb_data)\n+{\n+\tstruct string_list *upstreams = cb_data;\n+\tstruct branch *branch = branch_get(ref->name);\n+\tconst char *upstream = branch_get_upstream(branch, NULL);\n+\n+\tstring_list_append(upstreams, ref->name)->util =\n+\t\txstrdup_or_null(upstream);\n+\treturn 0;\n+}\n+\n+/*\n+ * Keep any branch that another, surviving branch tracks as its\n+ * upstream, so we never delete a branch out from under one stacked on\n+ * top of it.  Sparing a branch makes it a survivor whose own upstream\n+ * then needs the same protection, so repeat until nothing changes.\n+ */\n+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n+{\n+\tstruct string_list upstreams = STRING_LIST_INIT_DUP;\n+\tstruct string_list_item *item;\n+\tbool spared;\n+\n+\trefs_for_each_branch_ref(refs, collect_upstream, &upstreams);\n+\tdo {\n+\t\tspared = false;\n+\t\tfor_each_string_list_item(item, &upstreams) {\n+\t\t\tconst char *up = item->util, *up_short;\n+\n+\t\t\tif (!up || strset_contains(deletable, item->string))\n+\t\t\t\tcontinue;\n+\t\t\tif (!skip_prefix(up, \"refs/heads/\", &up_short) ||\n+\t\t\t    !strset_contains(deletable, up_short))\n+\t\t\t\tcontinue;\n+\n+\t\t\tstrset_remove(deletable, up_short);\n+\t\t\tspared = true;\n+\t\t}\n+\t} while (spared);\n+\n+\tstring_list_clear(&upstreams, 1);\n+}\n+\n+static int delete_merged_branches(int argc, const char **argv,\n+\t\t\t\t unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable = STRSET_INIT;\n+\tstruct strvec to_delete = STRVEC_INIT;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch_get_push(branch, NULL);\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(short_name, short_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable, short_name);\n+\t}\n+\n+\tspare_stacked_bases(refs, &deletable);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *short_name;\n+\n+\t\tif (skip_prefix(candidates.items[i]->refname, \"refs/heads/\",\n+\t\t\t\t&short_name) &&\n+\t\t    strset_contains(&deletable, short_name))\n+\t\t\tstrvec_push(&to_delete, short_name);\n+\t}\n+\n+\tif (to_delete.nr)\n+\t\tret = delete_branches(to_delete.nr, to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&to_delete);\n+\tstrset_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -746,6 +862,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint delete_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -799,6 +916,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -846,7 +965,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -856,7 +976,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -898,6 +1018,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged) {\n+\t\tret = delete_merged_branches(argc, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3104c555f6..1d372f95e8 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1839,4 +1839,155 @@ test_expect_success '--forked narrows a <pattern> argument' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" \"$2\" &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\" &&\n+\t\tgit fetch origin &&\n+\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n+\t)\n+}\n+\n+test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch merged origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit branch --set-upstream-to=origin/next unmerged &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout --detach\n+\t) &&\n+\tsha=$(git -C repo rev-parse --short merged) &&\n+\n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\n+\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tmain\n+\ttracks-other\n+\tunmerged\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch on-next origin/next &&\n+\tmerged_branch checked-out origin/next &&\n+\tmerged_branch upstream-gone origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b mainline main &&\n+\t\tgit checkout -b on-local mainline &&\n+\t\tgit branch --set-upstream-to=mainline on-local &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit branch --set-upstream-to=origin/main main &&\n+\t\tgit config branch.main.pushRemote origin &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout checked-out\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next mainline &&\n+\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tchecked-out\n+\tmain\n+\tmainline\n+\ttracks-other\n+\tupstream-gone\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n+test_expect_success '--delete-merged keeps a branch that is an upstream' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch feature origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\t\tgit branch --set-upstream-to=feature topic &&\n+\t\tgit checkout --detach\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next 2>err &&\n+\n+\ttest_must_be_empty err &&\n+\tgit -C repo rev-parse --verify refs/heads/feature &&\n+\tgit -C repo rev-parse --verify refs/heads/topic\n+'\n+\n+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch b3 origin/next &&\n+\t\tgit branch --set-upstream-to=origin/next b3 &&\n+\t\tgit branch b2 origin/next &&\n+\t\tgit branch --set-upstream-to=b3 b2 &&\n+\t\tgit checkout -b b1 b2 &&\n+\t\tgit commit --allow-empty -m \"b1 work\" &&\n+\t\tgit branch --set-upstream-to=b2 b1 &&\n+\t\tgit checkout --detach\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next &&\n+\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tb1\n+\tb2\n+\tb3\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546114","messageId":"27903fbb1d4f76da74cc55369b176ffca89f9b28.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:47Z","receivedAt":"2026-06-22T07:30:01Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 15 +++++++++++++++\n t/t3200-branch.sh                | 26 ++++++++++++++++++++++++++\n 4 files changed, 51 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 56ff889447..59ea3f471a 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -215,10 +215,11 @@ A branch is not deleted when:\n +\n --\n * its upstream remote-tracking branch no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * its push destination (`<branch>@{push}`) equals its upstream\n   (`<branch>@{upstream}`), so it cannot be distinguished from a\n-  branch that just looks \"fully merged\" right after a pull.\n+  branch that just looks \"fully merged\" right after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 35fd3e9efc..5ea610efa1 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -758,6 +758,8 @@ static int delete_merged_branches(int argc, const char **argv,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable = STRSET_INIT;\n \tstruct strvec to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tint i, ret = 0;\n \n \tif (!argc)\n@@ -775,6 +777,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n@@ -793,6 +796,17 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable, short_name);\n \t}\n \n@@ -814,6 +828,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&to_delete);\n \tstrset_clear(&deletable);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1d372f95e8..b80d558b4a 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1990,4 +1990,30 @@ test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch deleted origin/next &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch --delete-merged origin/next 2>err &&\n+\n+\ttest_grep \"Skipping .kept.\" err &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n+\tgit -C repo rev-parse --verify refs/heads/kept\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch -d kept &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546115","messageId":"49c1bcf1fb5a2f2bb45444658a5eb5c03ae509e3.1782113388.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v17 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-22T07:29:48Z","receivedAt":"2026-06-22T07:30:03Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --delete-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 22 +++++++++++++++++++---\n t/t3200-branch.sh             | 11 ++++++++++-\n 3 files changed, 36 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 59ea3f471a..e9f43ffa9e 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --delete-merged <branch>...\n+git branch [--dry-run] --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -230,6 +230,12 @@ A branch that another, surviving branch still tracks as its upstream\n is kept, so a branch is never deleted out from under one stacked on\n top of it.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 5ea610efa1..5e326e6c30 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n@@ -878,6 +887,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint delete_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -933,6 +943,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -995,6 +1007,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1035,7 +1050,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged) {\n \t\tret = delete_merged_branches(argc, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b80d558b4a..211e13481c 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest\n \t) &&\n \tsha=$(git -C repo rev-parse --short merged) &&\n \n-\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n+\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo rev-parse --verify refs/heads/merged &&\n \n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n \techo \"Deleted branch merged (was $sha).\" >expect &&\n \ttest_cmp expect actual &&\n \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n@@ -2016,4 +2020,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"546150","messageId":"55a8cc56-f015-4a1b-b830-79cf24870286@gmail.com","threadId":"65574","inReplyTo":"xmqq33yimsdp.fsf@gitster.g","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T09:07:57Z","receivedAt":"2026-06-22T09:08:00Z","isPatch":true,"body":"On 19/06/2026 17:01, Junio C Hamano wrote:\n> Junio C Hamano <gitster@pobox.com> writes:\n> \n>   - Notice that a branch that is deleted (because it itself is\n>     merged) is still depended upon by being @{upstream} of somebody\n>     else, and when it happens, fail the operation (i.e., do not\n>     delete the branch).\n\nNot deleting is definitely the safest option. As Harald has pointed out \nwe then need to think about what to do with the upstream branch of the \nbranch we're keeping because it is an upstream of another branch that's \nnot being deleted. Keeping the whole chain is probably safest.\n\n> What are the viable choices we can offer to the user in such a\n> situation?  I think there are a few viable choices.\n> \n>   - Make the dependent branch no longer depend on anything.  Asking\n>     feature2@{upstream} would fail.\n> \n>   - Move the @{upstream} of feature2 to the branch that \"merged\"\n>     feature1 and caused its removal.  Asking feature2@{upstream}\n>     would answer origin/master, which feature1 was removed after\n>     getting merged.\n\nThe second option is quite tempting. My only concern about doing it \nautomatically is that it would break \"git rebase --fork-point\" (which is \nthe default if you don't specify an upstream). If a user rewrites a \nbranch, merges it and then \"git branch --delete-merged\" deletes it and \nreassigns the upstream of the branches descended from it, rebasing those \nbranches would pull in some stale commits.\n\n> There may be others.  And there may be relationship similar to\n> feature1 vs feature2 that is not @{upstream} but something else that\n> makes a branch still \"depend on\" the other branch getting deleted.\n> \n> Do we also need the same safety around \"git branch -d feature1\" by\n> the way?  The \"-d\" option with safety checks the same \"is feature1\n> already merged (to its upstream)?\" condition, so it can protect the\n> feature2 branch the same way, by saying either \"oops, you cannot\n> delete feature1 because you still have other branches like feature2\n> that depend on it\", or \"ok, featur2 used to depend on feature1, but\n> because we are deleting feature1 based on it being in origin/master,\n> we will make feature2 depend on origin/master from now on\".\n\nI think handling branches that are the upstream for another branch the \nsame way for \"git branch -d\" and \"git branch --prune-merged\" is a good idea.\n\nThanks\n\nPhillip\n\n"},{"id":"546151","messageId":"31172867-5577-4c1e-b8b6-425ef9fe44e1@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnVce7NKft9AEyCUnR=S_y1ygiXjhf-qmJqmi-tuUXcw=g@mail.gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T09:09:52Z","receivedAt":"2026-06-22T09:09:55Z","isPatch":true,"body":"Hi Harald\n\nOn 21/06/2026 19:46, Harald Nordgren wrote:\n> Looking into this more and attempting to implement the logic for\n> re-assigning the upstream, it becomes quite a lot of code.\n> \n> Maybe an easier way forward now is to avoid deleting these cases. We\n> can always add the re-assigning logic later on without breaking\n> backward compatibility.\n\nNot deleting the branch is certainly safest and should be fairly easy to \nimplement. Adding an option to reassign the upstream later sounds fine \nto me.\n\nThanks\n\nPhillip\n\n"},{"id":"546153","messageId":"64ea35df-bdc9-42bd-ac16-5719c5bc6953@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnWt59h2HO5EJbFswYr7QEA7oNZKdBt_vTk5axNbWFZbpA@mail.gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T09:25:47Z","receivedAt":"2026-06-22T09:25:51Z","isPatch":true,"body":"Hi Harald\n\nOn 20/06/2026 10:04, Harald Nordgren wrote:\n>>   - Move the @{upstream} of feature2 to the branch that \"merged\"\n>>     feature1 and caused its removal.  Asking feature2@{upstream}\n>>     would answer origin/master, which feature1 was removed after\n>>     getting merged.\n> \n> I think this is a strong option.\n> \n> As a side note: I was annoyed before when GitHub didn't re-assign base\n> automatically when doing stacked PR's, so merging in the first branch\n> caused developers to  merge in the second PR into essentially a dead\n> feature branch instead of master, if they forgot to manually change\n> it. But I think GitHub has fixed this now so the second PR gets its\n> base changed to default branch.\n> \n> Two caveats:\n> \n> - How to handle recursion: b1 has b2 as upstream and b2 has b3 as\n> upstream, and both b2 and b3 have been merged? Not good if it's just\n> luck which order the branches get walked, but also we don't want to\n> have to do many passes, two passes is not even guaranteed to be\n> enough.\n\nI think you only need one pass. For each branch we look at it's upstream \nand if it is in the set of branches we want to delete we\n\n  1 remove it from the set of branches to be deleted\n  2 if the upstream of the upstream is in the set of branches to be\n    deleted goto 1.\n\nNote that we don't need to create a list of all upstream branches, we \ncan handle it within the refs_for_each_branch_ref() callback.\n\n> - What about when b3 has itself as upstream? I guess then we can just\n> remove the upstream of b2. Overall, I don't think it's a huge problem\n> when a branch gets no upstream, so maybe just warn about it.\n\nRemoving the upstream config of branches that are merged shouldn't cause \ntoo many problems - the user is unlikely to want to rebase a merged \nbranch and they're unlikely to have it checked out so \"git rebase\" and \n\"git log @{u}..\" probably does not matter.\n\nSo maybe we should change the loop above to only keep the upstream \nbranch of branches that have not been merged and instead clear the \nupstream config of any merged branches we keep because they are an \nupstream of another branch.\n\nThanks\n\nPhillip\n\n"},{"id":"546154","messageId":"a3bd3514-dab4-49b6-a210-bc7b8ddd701d@gmail.com","threadId":"65574","inReplyTo":"31172867-5577-4c1e-b8b6-425ef9fe44e1@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T09:28:15Z","receivedAt":"2026-06-22T09:28:18Z","isPatch":true,"body":"On 22/06/2026 10:09, Phillip Wood wrote:\n> Hi Harald\n> \n> On 21/06/2026 19:46, Harald Nordgren wrote:\n>> Looking into this more and attempting to implement the logic for\n>> re-assigning the upstream, it becomes quite a lot of code.\n\nHaving re-read you previous message I'm coming round to the idea of \nclearing the upstream of branches that have been merged but cannot be \ndeleted because they are the upstream of an unmerged branch. Is that \neasier than reassigning the upstream?\n\nThanks\n\nPhillip\n\n>> Maybe an easier way forward now is to avoid deleting these cases. We\n>> can always add the re-assigning logic later on without breaking\n>> backward compatibility.\n> \n> Not deleting the branch is certainly safest and should be fairly easy to \n> implement. Adding an option to reassign the upstream later sounds fine \n> to me.\n> \n> Thanks\n> \n> Phillip\n> \n\n"},{"id":"546155","messageId":"CAHwyqnVQwK1w9ap1-e=ii4a-BVOZ=oQtCdkTxB=TJF30kNaZoQ@mail.gmail.com","threadId":"65574","inReplyTo":"a3bd3514-dab4-49b6-a210-bc7b8ddd701d@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-22T09:37:18Z","receivedAt":"2026-06-22T09:37:57Z","isPatch":true,"body":"Hi! I implemented this in v17.\n\n\nHarald\n\nOn Mon, Jun 22, 2026 at 11:28 AM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> On 22/06/2026 10:09, Phillip Wood wrote:\n> > Hi Harald\n> >\n> > On 21/06/2026 19:46, Harald Nordgren wrote:\n> >> Looking into this more and attempting to implement the logic for\n> >> re-assigning the upstream, it becomes quite a lot of code.\n>\n> Having re-read you previous message I'm coming round to the idea of\n> clearing the upstream of branches that have been merged but cannot be\n> deleted because they are the upstream of an unmerged branch. Is that\n> easier than reassigning the upstream?\n>\n> Thanks\n>\n> Phillip\n>\n> >> Maybe an easier way forward now is to avoid deleting these cases. We\n> >> can always add the re-assigning logic later on without breaking\n> >> backward compatibility.\n> >\n> > Not deleting the branch is certainly safest and should be fairly easy to\n> > implement. Adding an option to reassign the upstream later sounds fine\n> > to me.\n> >\n> > Thanks\n> >\n> > Phillip\n> >\n>\n"},{"id":"546158","messageId":"ddf37b2d-29b4-4a3c-b7d9-b681af028b24@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnVQwK1w9ap1-e=ii4a-BVOZ=oQtCdkTxB=TJF30kNaZoQ@mail.gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T09:57:06Z","receivedAt":"2026-06-22T09:57:09Z","isPatch":true,"body":"On 22/06/2026 10:37, Harald Nordgren wrote:\n> Hi! I implemented this in v17.\n\nThat seems to preserve all the upstreams, rather than preserving the \nupstreams of unmerged branches and clearing the upstream setting of \nmerged branches. It also builds a list of upstream branches rather than \njust pruning the set of to-be-deleted branches in the \nrefs_for_each_branch_ref() callback which seems a bit wasteful.\n\nThanks\n\nPhillip\n\n> \n> Harald\n> \n> On Mon, Jun 22, 2026 at 11:28 AM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>>\n>> On 22/06/2026 10:09, Phillip Wood wrote:\n>>> Hi Harald\n>>>\n>>> On 21/06/2026 19:46, Harald Nordgren wrote:\n>>>> Looking into this more and attempting to implement the logic for\n>>>> re-assigning the upstream, it becomes quite a lot of code.\n>>\n>> Having re-read you previous message I'm coming round to the idea of\n>> clearing the upstream of branches that have been merged but cannot be\n>> deleted because they are the upstream of an unmerged branch. Is that\n>> easier than reassigning the upstream?\n>>\n>> Thanks\n>>\n>> Phillip\n>>\n>>>> Maybe an easier way forward now is to avoid deleting these cases. We\n>>>> can always add the re-assigning logic later on without breaking\n>>>> backward compatibility.\n>>>\n>>> Not deleting the branch is certainly safest and should be fairly easy to\n>>> implement. Adding an option to reassign the upstream later sounds fine\n>>> to me.\n>>>\n>>> Thanks\n>>>\n>>> Phillip\n>>>\n>>\n\n"},{"id":"546166","messageId":"CAHwyqnWztMhKkDGkfh0cZzT+gAuFZ4_n-vhvJsXrSY1apgqSRQ@mail.gmail.com","threadId":"65574","inReplyTo":"ddf37b2d-29b4-4a3c-b7d9-b681af028b24@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-06-22T10:52:46Z","receivedAt":"2026-06-22T10:53:25Z","isPatch":true,"body":"> clearing the upstream setting of\n> merged branches.\n\nWhy do we need to do that, they will be deleted anyway since they are merged?\n\n> It also builds a list of upstream branches rather than\n> just pruning the set of to-be-deleted branches in the\n> refs_for_each_branch_ref() callback which seems a bit wasteful.\n\nGood point.\n\n\nHarald\n"},{"id":"546170","messageId":"xmqqbjd24v7n.fsf@gitster.g","threadId":"65574","inReplyTo":"55a8cc56-f015-4a1b-b830-79cf24870286@gmail.com","subject":"Re: [PATCH v14 4/6] branch: add --prune-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-22T12:26:36Z","receivedAt":"2026-06-22T12:26:39Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> On 19/06/2026 17:01, Junio C Hamano wrote:\n>> Junio C Hamano <gitster@pobox.com> writes:\n>> \n>>   - Notice that a branch that is deleted (because it itself is\n>>     merged) is still depended upon by being @{upstream} of somebody\n>>     else, and when it happens, fail the operation (i.e., do not\n>>     delete the branch).\n>\n> Not deleting is definitely the safest option. As Harald has pointed out \n> we then need to think about what to do with the upstream branch of the \n> branch we're keeping because it is an upstream of another branch that's \n> not being deleted. Keeping the whole chain is probably safest.\n\nYou're right.\n\n> I think handling branches that are the upstream for another branch the \n> same way for \"git branch -d\" and \"git branch --prune-merged\" is a good idea.\n\nYeah.  The latter should be a short-hand for\n\n    for each branch\n    do\n\tif branch is merged to its upstream\n\tthen\n\t    remember to remove it\n\tfi\n    done\n    now remove those we scheduled to remove with \"git branch -d\".\n\nso it would be clean if we can explain that philosophically the\nsafety lies within \"gir branch -d\".\n\nThanks.\n\n"},{"id":"546187","messageId":"cb6fcdfb-67b4-429d-b820-c4e623f28cfa@gmail.com","threadId":"65574","inReplyTo":"46da7c814056ddbc23accf19a61d0451b949127e.1782113388.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v17 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-06-22T15:36:59Z","receivedAt":"2026-06-22T15:37:11Z","isPatch":true,"body":"Hi Harald\n\nOn 22/06/2026 08:29, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> +static int collect_upstream(const struct reference *ref, void *cb_data)\n> +{\n> +\tstruct string_list *upstreams = cb_data;\n> +\tstruct branch *branch = branch_get(ref->name);\n> +\tconst char *upstream = branch_get_upstream(branch, NULL);\n> +\n> +\tstring_list_append(upstreams, ref->name)->util =\n> +\t\txstrdup_or_null(upstream);\n> +\treturn 0;\n> +}\n> +\n> +/*\n> + * Keep any branch that another, surviving branch tracks as its\n> + * upstream, so we never delete a branch out from under one stacked on\n> + * top of it.  Sparing a branch makes it a survivor whose own upstream\n> + * then needs the same protection, so repeat until nothing changes.\n> + */\n> +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n> +{\n> +\tstruct string_list upstreams = STRING_LIST_INIT_DUP;\n> +\tstruct string_list_item *item;\n> +\tbool spared;\n> +\n> +\trefs_for_each_branch_ref(refs, collect_upstream, &upstreams);\n> +\tdo {\n> +\t\tspared = false;\n> +\t\tfor_each_string_list_item(item, &upstreams) {\n> +\t\t\tconst char *up = item->util, *up_short;\n> +\n> +\t\t\tif (!up || strset_contains(deletable, item->string))\n> +\t\t\t\tcontinue;\n> +\t\t\tif (!skip_prefix(up, \"refs/heads/\", &up_short) ||\n> +\t\t\t    !strset_contains(deletable, up_short))\n> +\t\t\t\tcontinue;\n> +\n> +\t\t\tstrset_remove(deletable, up_short);\n> +\t\t\tspared = true;\n> +\t\t}\n> +\t} while (spared);\n> +\n> +\tstring_list_clear(&upstreams, 1);\n> +}\n\nThis keeps the whole chain of branches, which is the safest thing to\ndo but potentially keeps unneeded branches around. It is only really\nthe upstream branches of unmerged branches which are useful so we\ncould just keep those, and if their upstream branch is going to be\ndeleted clear the upstream config for that branch.\n\nFor example, if we have branch feature-3 with upstream feature-2 which\nhas upstream feature-1, then if feature-1 and feature-2 are merged we'd\ndelete feature-1 but keep feature-2 and clear its upstream config. If we\nalso had feature-4 that was not merged and had upstream feature-1 we'd\nkeep feature-1 and leave the upstream config for feature-2 unchanged.\n\nHere is a possible implementation for that. It compiles but I've not\nwritten any new tests. It does pass the existing tests which means\neither it is buggy or we don't have coverage for keeping a chain of\nbranches.\n\nstatic int collect_upstreams(const struct reference *ref, void *cb_data)\n{\n\tstruct collect_upstream_data *data = cb_data;\n\tstruct strset *deletable = data->deletable;\n\tstruct strset *upstreams = data->upstreams;\n\tstruct branch *branch;\n\tconst char *upstream_ref, *upstream_name;\n\n\t/*\n\t * We're only interested in the upstreams of branches that\n\t * are not being deleted.\n\t */\n\tif (strset_contains(deletable, ref->name))\n\t\treturn 0;\n\tbranch = branch_get(ref->name);\n\tif (!branch)\n\t\treturn 0;\n\tupstream_ref = branch_get_upstream(branch, NULL);\n\t/*\n\t * We're only interested in the upstream if it is going to\n\t * be deleted.\n\t */\n\tif (!upstream_ref ||\n\t    !skip_prefix(upstream_ref, \"refs/heads/\", &upstream_name) ||\n\t    !strset_contains(deletable, upstream_name))\n\t\treturn 0;\n\t/*\n\t * Do not delete this branch because it is the upstream of\n\t * an unmerged branch. Also remember it so we can check if\n\t * its upstream is marked for deletion once we've visited all\n\t * branches\n\t */\n\tstrset_remove(deletable, upstream_name);\n\tstrset_add(upstreams, upstream_name);\n\n\treturn 0;\n}\n\n/*\n  * Keep any branch that another, surviving branch tracks as its\n  * upstream, so we never delete a branch out from under one stacked on\n  * top of it.  If the upstream branch has an upstream set that is marked\n  * for deletion clear its upstream config.\n  */\nstatic void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n{\n\tstruct strset upstreams = STRSET_INIT;\n\tstruct collect_upstream_data data = {\n\t\t.deletable = deletable,\n\t\t.upstreams = &upstreams,\n\t};\n\tstruct strbuf buf = STRBUF_INIT;\n\tstruct hashmap_iter iter;\n\tstruct strmap_entry *entry;\n\n\trefs_for_each_branch_ref(refs, collect_upstreams, &data);\n\tstrset_for_each_entry(&upstreams, &iter, entry) {\n\t\tconst char *upstream_upstream;\n\t\tstruct branch *upstream_branch;\n\n\t\t/* We know upstream_ref is a branch, skip \"refs/heads/\" */\n\t\tupstream_branch = branch_get(entry->key);\n\t\tupstream_upstream = branch_get_upstream(upstream_branch, NULL);\n\t\tif (upstream_upstream &&\n\t\t    strset_contains(deletable, upstream_upstream)) {\n\t\t\t/*\n\t\t\t * This branch has been merged and is the upstream of\n\t\t\t * an unmerged branch.  Its upstream is marked for\n\t\t\t * deletion because it is not the upstream of any\n\t\t\t * unmerged branch so clear its upstream config.\n\t\t\t */\n\t\t\tstrbuf_reset(&buf);\n\t\t\tstrbuf_addf(&buf, \"branch.%s.merge\", upstream_branch->name);\n\t\t\trepo_config_set_gently(the_repository, buf.buf, NULL);\n\t\t\tstrbuf_setlen(&buf, buf.len - 5);\n\t\t\tstrbuf_addstr(&buf, \"remote\");\n\t\t\trepo_config_set_gently(the_repository, buf.buf, NULL);\n\t\t}\n\t}\n\tstrbuf_release(&buf);\n\tstrset_clear(&upstreams);\n\n}\n\n\n> +\tfor (i = 0; i < candidates.nr; i++) {\n> +\t\tconst char *short_name;\n> +\n> +\t\tif (skip_prefix(candidates.items[i]->refname, \"refs/heads/\",\n> +\t\t\t\t&short_name) &&\n> +\t\t    strset_contains(&deletable, short_name))\n> +\t\t\tstrvec_push(&to_delete, short_name);\n> +\t}\n\nIt would be nicer to use strset_for_each_entry() here. First declare\n\n\tstruct hashmap_iter iter;\n\tstruct strmap_entry *entry;\n\nat the top of the function and then replace the loop with\n\n\tstrset_for_each_entry(&deletable, &iter, entry)\n\t\tstrvec_push(&to_delete, entry->key);\n\nThanks\n\nPhillip\n\n\n> +\tif (to_delete.nr)\n> +\t\tret = delete_branches(to_delete.nr, to_delete.v,\n> +\t\t\t\t      FILTER_REFS_BRANCHES,\n> +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n> +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n> +\t\t\t\t      flags);\n> +\n> +\tstrvec_clear(&to_delete);\n> +\tstrset_clear(&deletable);\n> +\tref_array_clear(&candidates);\n> +\tref_filter_clear(&filter);\n> +\treturn ret;\n> +}\n> +\n>   static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n>   \n>   static int edit_branch_description(const char *branch_name)\n> @@ -746,6 +862,7 @@ int cmd_branch(int argc,\n>   \t/* possible actions */\n>   \tint delete = 0, rename = 0, copy = 0, list = 0,\n>   \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n> +\tint delete_merged = 0;\n>   \tconst char *new_upstream = NULL;\n>   \tint noncreate_actions = 0;\n>   \t/* possible options */\n> @@ -799,6 +916,8 @@ int cmd_branch(int argc,\n>   \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n>   \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n>   \t\t\t N_(\"edit the description for the branch\")),\n> +\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n> +\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n>   \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n>   \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n>   \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n> @@ -846,7 +965,8 @@ int cmd_branch(int argc,\n>   \t\t\t     0);\n>   \n>   \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n> -\t    !show_current && !unset_upstream && argc == 0)\n> +\t    !show_current && !unset_upstream && !delete_merged &&\n> +\t    argc == 0)\n>   \t\tlist = 1;\n>   \n>   \tif (filter.with_commit || filter.no_commit ||\n> @@ -856,7 +976,7 @@ int cmd_branch(int argc,\n>   \n>   \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n>   \t\t\t    !!show_current + !!list + !!edit_description +\n> -\t\t\t    !!unset_upstream;\n> +\t\t\t    !!unset_upstream + !!delete_merged;\n>   \tif (noncreate_actions > 1)\n>   \t\tusage_with_options(builtin_branch_usage, options);\n>   \n> @@ -898,6 +1018,10 @@ int cmd_branch(int argc,\n>   \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n>   \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>   \t\tgoto out;\n> +\t} else if (delete_merged) {\n> +\t\tret = delete_merged_branches(argc, argv,\n> +\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n> +\t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n>   \t\tret = 0;\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 3104c555f6..1d372f95e8 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1839,4 +1839,155 @@ test_expect_success '--forked narrows a <pattern> argument' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--delete-merged: setup' '\n> +\tgit init -b main upstream &&\n> +\t(\n> +\t\tcd upstream &&\n> +\t\ttest_commit base &&\n> +\t\tgit checkout -b next &&\n> +\t\ttest_commit next-work &&\n> +\t\tgit checkout main\n> +\t) &&\n> +\tgit init -b main other &&\n> +\ttest_commit -C other other-base &&\n> +\tgit init -b main fork\n> +'\n> +\n> +setup_repo_for_delete_merged () {\n> +\trm -rf repo &&\n> +\tgit clone upstream repo &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit remote add fork ../fork &&\n> +\t\tgit remote add other ../other &&\n> +\t\tgit config remote.pushDefault fork &&\n> +\t\tgit config push.default current &&\n> +\t\tgit fetch other\n> +\t)\n> +}\n> +\n> +merged_branch () {\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b \"$1\" \"$2\" &&\n> +\t\tgit commit --allow-empty -m \"$1 work\" &&\n> +\t\tgit push origin \"$1:next\" &&\n> +\t\tgit fetch origin &&\n> +\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n> +\t)\n> +}\n> +\n> +test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch merged origin/next &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b unmerged origin/next &&\n> +\t\tgit commit --allow-empty -m \"unmerged work\" &&\n> +\t\tgit branch --set-upstream-to=origin/next unmerged &&\n> +\t\tgit checkout -b tracks-other other/main &&\n> +\t\tgit branch --set-upstream-to=other/main tracks-other &&\n> +\t\tgit checkout --detach\n> +\t) &&\n> +\tsha=$(git -C repo rev-parse --short merged) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n> +\n> +\techo \"Deleted branch merged (was $sha).\" >expect &&\n> +\ttest_cmp expect actual &&\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tmain\n> +\ttracks-other\n> +\tunmerged\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch on-next origin/next &&\n> +\tmerged_branch checked-out origin/next &&\n> +\tmerged_branch upstream-gone origin/next &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b mainline main &&\n> +\t\tgit checkout -b on-local mainline &&\n> +\t\tgit branch --set-upstream-to=mainline on-local &&\n> +\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n> +\t\tgit update-ref -d refs/remotes/origin/topic &&\n> +\t\tgit branch --set-upstream-to=origin/main main &&\n> +\t\tgit config branch.main.pushRemote origin &&\n> +\t\tgit checkout -b tracks-other other/main &&\n> +\t\tgit branch --set-upstream-to=other/main tracks-other &&\n> +\t\tgit checkout checked-out\n> +\t) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next mainline &&\n> +\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tchecked-out\n> +\tmain\n> +\tmainline\n> +\ttracks-other\n> +\tupstream-gone\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--delete-merged requires at least one <branch>' '\n> +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n> +\ttest_grep \"requires at least one <branch>\" err\n> +'\n> +\n> +test_expect_success '--delete-merged keeps a branch that is an upstream' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch feature origin/next &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b topic feature &&\n> +\t\tgit commit --allow-empty -m \"topic work\" &&\n> +\t\tgit branch --set-upstream-to=feature topic &&\n> +\t\tgit checkout --detach\n> +\t) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next 2>err &&\n> +\n> +\ttest_must_be_empty err &&\n> +\tgit -C repo rev-parse --verify refs/heads/feature &&\n> +\tgit -C repo rev-parse --verify refs/heads/topic\n> +'\n> +\n> +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit branch b3 origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/next b3 &&\n> +\t\tgit branch b2 origin/next &&\n> +\t\tgit branch --set-upstream-to=b3 b2 &&\n> +\t\tgit checkout -b b1 b2 &&\n> +\t\tgit commit --allow-empty -m \"b1 work\" &&\n> +\t\tgit branch --set-upstream-to=b2 b1 &&\n> +\t\tgit checkout --detach\n> +\t) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next &&\n> +\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tb1\n> +\tb2\n> +\tb3\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n>   test_done\n\n"},{"id":"546355","messageId":"3e29ff17bd703d8333c2d65d36b15c69ddfc2ab9.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:00Z","receivedAt":"2026-06-24T21:55:11Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 ++++-\n ref-filter.c                  |  70 +++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 122 ++++++++++++++++++++++++++++++++++\n 5 files changed, 229 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 8ba91c72a1..6ee2328bdb 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..3104c555f6 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit remote set-head origin one &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n+\t\tgit checkout --detach\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\ttest_when_finished \"git -C forked checkout --detach\" &&\n+\tgit -C forked checkout local-one &&\n+\ttest_commit -C forked local-only &&\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546356","messageId":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v17.git.git.1782113388.gitgitgadget@gmail.com","subject":"[PATCH v18 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:54:59Z","receivedAt":"2026-06-24T21:55:11Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  48 ++++-\n builtin/branch.c                 | 266 +++++++++++++++++++++---\n ref-filter.c                     |  70 +++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 342 +++++++++++++++++++++++++++++++\n 6 files changed, 715 insertions(+), 28 deletions(-)\n\n\nbase-commit: ab776a62a78576513ee121424adb19597fbb7613\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v17:\n\n 1:  d8cc17bd7f = 1:  3e29ff17bd branch: add --forked filter for --list mode\n 2:  d14b0403f0 = 2:  cdd4fea4a7 branch: convert delete_branches() to a flags argument\n 3:  ef2719dac3 = 3:  a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  80518f5d11 = 4:  a56d8fe93e branch: prepare delete_branches for a bulk caller\n 5:  46da7c8140 ! 5:  a84c555d99 branch: add --delete-merged <branch>\n     @@ Commit message\n          upstream. The work has already landed on the upstream they track,\n          so the local copy is no longer needed.\n      \n     -    Three kinds of branches are not deleted:\n     +    A branch is not deleted when:\n      \n     -      * any branch checked out in any worktree\n     -      * any branch whose upstream remote-tracking branch no longer\n     -        exists, since a missing upstream is not by itself a sign of\n     -        integration\n     -      * any branch whose push destination equals its upstream\n     -        (<branch>@{push} is the same as <branch>@{upstream}), such as\n     -        a local \"main\" that tracks and pushes to \"origin/main\". Right\n     -        after a pull it just looks \"fully merged\", so it is kept. Only\n     -        branches that push somewhere other than their upstream,\n     -        typically topics in a fork workflow, are candidates.\n     +      * it is checked out in any worktree\n     +      * its upstream remote-tracking branch no longer exists, since a\n     +        missing upstream is not by itself a sign of integration\n     +      * its push destination equals its upstream (<branch>@{push} is\n     +        the same as <branch>@{upstream}), such as a local \"main\" that\n     +        tracks and pushes to \"origin/main\". Right after a pull it just\n     +        looks \"fully merged\", so it is kept. Only branches that push\n     +        somewhere other than their upstream, typically topics in a fork\n     +        workflow, are candidates.\n      \n          A branch whose work is not yet merged into its upstream is silently\n          skipped, so one unmerged topic does not abort the whole sweep.\n      \n          A branch that another, surviving branch tracks as its upstream is\n          also kept, so a branch is never deleted out from under one stacked\n     -    on top of it. Sparing such a base can in turn protect its own\n     -    upstream, so the check repeats until the set stops changing.\n     +    on top of it. Such a kept branch is itself merged, so when its own\n     +    upstream is being deleted, clear its now-stale upstream config.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n      +silently skipped. Delete it with `git branch -D` if you want to\n      +remove it anyway.\n      ++\n     -+A branch that another, surviving branch still tracks as its upstream\n     -+is kept, so a branch is never deleted out from under one stacked on\n     -+top of it.\n     ++A branch that another, surviving branch tracks as its upstream is\n     ++kept, so a branch is never deleted out from under one stacked on top\n     ++of it. If that kept branch in turn tracks a branch that is being\n     ++deleted, its now-stale upstream configuration is cleared.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n       \treturn 0;\n       }\n       \n     -+static int collect_upstream(const struct reference *ref, void *cb_data)\n     -+{\n     -+\tstruct string_list *upstreams = cb_data;\n     -+\tstruct branch *branch = branch_get(ref->name);\n     -+\tconst char *upstream = branch_get_upstream(branch, NULL);\n     ++struct spare_data {\n     ++\tstruct strset *deletable;\n     ++\tstruct strset *spared;\n     ++};\n      +\n     -+\tstring_list_append(upstreams, ref->name)->util =\n     -+\t\txstrdup_or_null(upstream);\n     ++/*\n     ++ * A surviving branch stacked on a deletion candidate would lose its\n     ++ * upstream, so drop that candidate from the delete set and remember it\n     ++ * in \"spared\" so its own upstream can be tidied up afterwards.\n     ++ */\n     ++static int spare_stacked_base(const struct reference *ref, void *cb_data)\n     ++{\n     ++\tstruct spare_data *data = cb_data;\n     ++\tstruct branch *branch;\n     ++\tconst char *upstream, *up_short;\n     ++\n     ++\tif (strset_contains(data->deletable, ref->name))\n     ++\t\treturn 0;\n     ++\tbranch = branch_get(ref->name);\n     ++\tupstream = branch_get_upstream(branch, NULL);\n     ++\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     ++\t    !strset_contains(data->deletable, up_short))\n     ++\t\treturn 0;\n     ++\n     ++\tstrset_remove(data->deletable, up_short);\n     ++\tstrset_add(data->spared, up_short);\n      +\treturn 0;\n      +}\n      +\n      +/*\n     -+ * Keep any branch that another, surviving branch tracks as its\n     -+ * upstream, so we never delete a branch out from under one stacked on\n     -+ * top of it.  Sparing a branch makes it a survivor whose own upstream\n     -+ * then needs the same protection, so repeat until nothing changes.\n     ++ * Keep any branch that a surviving branch tracks as its upstream, so we\n     ++ * never delete a branch out from under one stacked on top of it.  Such a\n     ++ * base is itself merged, so when its own upstream is also going away\n     ++ * (no surviving branch tracks it), clear the base's now-stale upstream.\n      + */\n      +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n      +{\n     -+\tstruct string_list upstreams = STRING_LIST_INIT_DUP;\n     -+\tstruct string_list_item *item;\n     -+\tbool spared;\n     -+\n     -+\trefs_for_each_branch_ref(refs, collect_upstream, &upstreams);\n     -+\tdo {\n     -+\t\tspared = false;\n     -+\t\tfor_each_string_list_item(item, &upstreams) {\n     -+\t\t\tconst char *up = item->util, *up_short;\n     -+\n     -+\t\t\tif (!up || strset_contains(deletable, item->string))\n     -+\t\t\t\tcontinue;\n     -+\t\t\tif (!skip_prefix(up, \"refs/heads/\", &up_short) ||\n     -+\t\t\t    !strset_contains(deletable, up_short))\n     -+\t\t\t\tcontinue;\n     -+\n     -+\t\t\tstrset_remove(deletable, up_short);\n     -+\t\t\tspared = true;\n     -+\t\t}\n     -+\t} while (spared);\n     -+\n     -+\tstring_list_clear(&upstreams, 1);\n     ++\tstruct strset spared = STRSET_INIT;\n     ++\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n     ++\tstruct strbuf key = STRBUF_INIT;\n     ++\tstruct hashmap_iter iter;\n     ++\tstruct strmap_entry *entry;\n     ++\n     ++\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n     ++\n     ++\tstrset_for_each_entry(&spared, &iter, entry) {\n     ++\t\tstruct branch *branch = branch_get(entry->key);\n     ++\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     ++\t\tconst char *up_short;\n     ++\n     ++\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     ++\t\t    !strset_contains(deletable, up_short))\n     ++\t\t\tcontinue;\n     ++\n     ++\t\tstrbuf_reset(&key);\n     ++\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\tstrbuf_reset(&key);\n     ++\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t}\n     ++\n     ++\tstrbuf_release(&key);\n     ++\tstrset_clear(&spared);\n      +}\n      +\n      +static int delete_merged_branches(int argc, const char **argv,\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct ref_array candidates = { 0 };\n      +\tstruct strset deletable = STRSET_INIT;\n      +\tstruct strvec to_delete = STRVEC_INIT;\n     ++\tstruct hashmap_iter iter;\n     ++\tstruct strmap_entry *entry;\n      +\tint i, ret = 0;\n      +\n      +\tif (!argc)\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\n      +\tspare_stacked_bases(refs, &deletable);\n      +\n     -+\tfor (i = 0; i < candidates.nr; i++) {\n     -+\t\tconst char *short_name;\n     -+\n     -+\t\tif (skip_prefix(candidates.items[i]->refname, \"refs/heads/\",\n     -+\t\t\t\t&short_name) &&\n     -+\t\t    strset_contains(&deletable, short_name))\n     -+\t\t\tstrvec_push(&to_delete, short_name);\n     -+\t}\n     ++\tstrset_for_each_entry(&deletable, &iter, entry)\n     ++\t\tstrvec_push(&to_delete, entry->key);\n      +\n      +\tif (to_delete.nr)\n      +\t\tret = delete_branches(to_delete.nr, to_delete.v,\n     @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n      +\t\tgit checkout --detach\n      +\t) &&\n      +\n     ++\tgit -C repo branch --dry-run --delete-merged origin/next >out &&\n     ++\ttest_grep ! \"feature\" out &&\n     ++\n      +\tgit -C repo branch --delete-merged origin/next 2>err &&\n      +\n      +\ttest_must_be_empty err &&\n      +\tgit -C repo rev-parse --verify refs/heads/feature &&\n     -+\tgit -C repo rev-parse --verify refs/heads/topic\n     ++\tgit -C repo rev-parse --verify refs/heads/topic &&\n     ++\techo origin/next >expect &&\n     ++\tgit -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n     ++\ttest_cmp expect actual &&\n     ++\techo feature >expect &&\n     ++\tgit -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n     ++\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n     @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n      +\tEOF\n      +\ttest_cmp expect actual\n      +'\n     ++\n     ++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n     ++\ttest_when_finished \"rm -rf repo\" &&\n     ++\tsetup_repo_for_delete_merged &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit branch lower origin/next &&\n     ++\t\tgit branch --set-upstream-to=origin/next lower &&\n     ++\t\tgit branch mid origin/next &&\n     ++\t\tgit branch --set-upstream-to=lower mid &&\n     ++\t\tgit checkout -b tip mid &&\n     ++\t\tgit commit --allow-empty -m \"tip work\" &&\n     ++\t\tgit branch --set-upstream-to=mid tip &&\n     ++\t\tgit checkout --detach\n     ++\t) &&\n     ++\n     ++\tgit -C repo branch --delete-merged origin/next lower &&\n     ++\n     ++\ttest_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n     ++\tgit -C repo rev-parse --verify refs/heads/mid &&\n     ++\ttest_must_fail git -C repo rev-parse mid@{upstream} &&\n     ++\techo mid >expect &&\n     ++\tgit -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n     ++\ttest_cmp expect actual\n     ++'\n      +\n       test_done\n 6:  27903fbb1d ! 6:  d52d717b70 branch: add branch.<name>.deleteMerged opt-out\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \tstruct strset deletable = STRSET_INIT;\n       \tstruct strvec to_delete = STRVEC_INIT;\n      +\tstruct strbuf key = STRBUF_INIT;\n     + \tstruct hashmap_iter iter;\n     + \tstruct strmap_entry *entry;\n      +\tbool quiet = flags & DELETE_BRANCH_QUIET;\n       \tint i, ret = 0;\n       \n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \tref_array_clear(&candidates);\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow\n       \ttest_cmp expect actual\n       '\n       \n 7:  49c1bcf1fb ! 7:  8d0323f4b3 branch: add --dry-run for --delete-merged\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream\n     - is kept, so a branch is never deleted out from under one stacked on\n     - top of it.\n     +@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top\n     + of it. If that kept branch in turn tracks a branch that is being\n     + deleted, its now-stale upstream configuration is cleared.\n       \n      +`--dry-run`::\n      +\tWith `--delete-merged`, print which branches would be\n\n-- \ngitgitgadget\n"},{"id":"546357","messageId":"cdd4fea4a73e39a1f88127037d806c9b6182d01e.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:01Z","receivedAt":"2026-06-24T21:55:12Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() and check_branch_commit() take a pair of int\nbooleans (force and quiet) that the next commits would grow further.\nReplace them with a single \"unsigned int flags\" argument and an\nenum, splitting the bits back into named bool locals so the body\nkeeps reading the same named values.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 36 ++++++++++++++++++++++++------------\n 1 file changed, 24 insertions(+), 12 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..a9be980aef 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,10 +189,16 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n+\tbool force = flags & DELETE_BRANCH_FORCE;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \tint i;\n \tint ret = 0;\n \tint remote_branch = 0;\n+\tbool force;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n+\tforce = flags & DELETE_BRANCH_FORCE;\n+\n \tif (!force)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -872,7 +882,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"546358","messageId":"a0fd5b4a6c1b9d7823b431be703ee1696ea41f6c.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:02Z","receivedAt":"2026-06-24T21:55:13Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 17 ++++++++++++-----\n 1 file changed, 12 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex a9be980aef..4c569d056a 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -199,16 +200,20 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       int kinds, unsigned int flags)\n {\n \tbool force = flags & DELETE_BRANCH_FORCE;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n \tif (!force && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!skip_unmerged) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tint remote_branch = 0;\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!skip_unmerged)\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"546359","messageId":"a56d8fe93e041d640b0049bcc37459ffedab795c.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:03Z","receivedAt":"2026-06-24T21:55:15Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() two new modes for the upcoming\n--delete-merged: one that asks only whether a branch is merged into\nits upstream, without falling back to HEAD when there is no\nupstream, and one that rehearses the deletions without removing any\nref. Existing callers keep their current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 4c569d056a..01c1f64c73 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -241,6 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool force;\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n+\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -268,7 +273,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \n \tforce = flags & DELETE_BRANCH_FORCE;\n \n-\tif (!force)\n+\tif (!force && !no_head_fallback)\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"546362","messageId":"d52d717b70c80b668e6d3a1fdf186ab4846664c7.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:05Z","receivedAt":"2026-06-24T21:55:18Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 15 +++++++++++++++\n t/t3200-branch.sh                | 26 ++++++++++++++++++++++++++\n 4 files changed, 51 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 66b1c87c55..d482cded3d 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -215,10 +215,11 @@ A branch is not deleted when:\n +\n --\n * its upstream remote-tracking branch no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * its push destination (`<branch>@{push}`) equals its upstream\n   (`<branch>@{upstream}`), so it cannot be distinguished from a\n-  branch that just looks \"fully merged\" right after a pull.\n+  branch that just looks \"fully merged\" right after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex d12a2f57ea..bce85cb52e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -781,8 +781,10 @@ static int delete_merged_branches(int argc, const char **argv,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable = STRSET_INIT;\n \tstruct strvec to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tint i, ret = 0;\n \n \tif (!argc)\n@@ -800,6 +802,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream, *push;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n@@ -818,6 +821,17 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!quiet)\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable, short_name);\n \t}\n \n@@ -833,6 +847,7 @@ static int delete_merged_branches(int argc, const char **argv,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&to_delete);\n \tstrset_clear(&deletable);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 047ba54778..b7595610d9 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch deleted origin/next &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch --delete-merged origin/next 2>err &&\n+\n+\ttest_grep \"Skipping .kept.\" err &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n+\tgit -C repo rev-parse --verify refs/heads/kept\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch kept origin/next &&\n+\tgit -C repo config branch.kept.deleteMerged false &&\n+\tgit -C repo checkout --detach &&\n+\n+\tgit -C repo branch -d kept &&\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"546364","messageId":"8d0323f4b30cdfed134ff2840cc8a9ab32f9db53.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:06Z","receivedAt":"2026-06-24T21:55:21Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nWith --dry-run, --delete-merged prints the local branches it would\ndelete, one \"Would delete branch <name>\" line each, and exits\nwithout touching any ref. The same filtering applies, so the output\nis exactly the set that the real run would delete.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 22 +++++++++++++++++++---\n t/t3200-branch.sh             | 11 ++++++++++-\n 3 files changed, 36 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex d482cded3d..00d6192e6a 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch --delete-merged <branch>...\n+git branch [--dry-run] --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -231,6 +231,12 @@ kept, so a branch is never deleted out from under one stacked on top\n of it. If that kept branch in turn tracks a branch that is being\n deleted, its now-stale upstream configuration is cleared.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex bce85cb52e..e7763437fb 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -248,6 +249,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \tbool quiet = flags & DELETE_BRANCH_QUIET;\n \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n \tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n \tstruct strbuf bname = STRBUF_INIT;\n \tenum interpret_branch_kind allowed_interpret;\n \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n@@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!dry_run &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (dry_run) {\n+\t\t\tif (!quiet)\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!quiet)\n \t\t\t\tprintf(remote_branch\n@@ -897,6 +906,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tint delete_merged = 0;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -952,6 +962,8 @@ int cmd_branch(int argc,\n \t\t\t N_(\"edit the description for the branch\")),\n \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1014,6 +1026,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1054,7 +1069,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged) {\n \t\tret = delete_merged_branches(argc, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex b7595610d9..cddcde341d 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest\n \t) &&\n \tsha=$(git -C repo rev-parse --short merged) &&\n \n-\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n+\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo rev-parse --verify refs/heads/merged &&\n \n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n \techo \"Deleted branch merged (was $sha).\" >expect &&\n \ttest_cmp expect actual &&\n \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n@@ -2050,4 +2054,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"546365","messageId":"a84c555d99c98abcd4618833c14fa35b191fbda2.1782338106.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-06-24T21:55:04Z","receivedAt":"2026-06-24T21:55:22Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\tgit branch --delete-merged <branch>...\n\ndeletes the local branches that \"--forked <branch>\" would list,\nkeeping only those whose tip is reachable from their configured\nupstream. The work has already landed on the upstream they track,\nso the local copy is no longer needed.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its upstream remote-tracking branch no longer exists, since a\n    missing upstream is not by itself a sign of integration\n  * its push destination equals its upstream (<branch>@{push} is\n    the same as <branch>@{upstream}), such as a local \"main\" that\n    tracks and pushes to \"origin/main\". Right after a pull it just\n    looks \"fully merged\", so it is kept. Only branches that push\n    somewhere other than their upstream, typically topics in a fork\n    workflow, are candidates.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that another, surviving branch tracks as its upstream is\nalso kept, so a branch is never deleted out from under one stacked\non top of it. Such a kept branch is itself merged, so when its own\nupstream is being deleted, clear its now-stale upstream config.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  29 ++++++\n builtin/branch.c              | 147 ++++++++++++++++++++++++++-\n t/t3200-branch.sh             | 185 ++++++++++++++++++++++++++++++++++\n 3 files changed, 359 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..66b1c87c55 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch --delete-merged <branch>...\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,34 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>...`::\n+\tDelete the local branches that `--forked` would list for the\n+\tgiven _<branch>_ arguments, but only those whose tip is\n+\treachable from their configured upstream. In other words, the\n+\twork on the branch has already landed on the upstream it\n+\ttracks, so the local copy is no longer needed. Several\n+\t_<branch>_ patterns may be given, e.g. `git branch\n+\t--delete-merged origin/main 'feature*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its upstream remote-tracking branch no longer exists,\n+* it is checked out in any worktree, or\n+* its push destination (`<branch>@{push}`) equals its upstream\n+  (`<branch>@{upstream}`), so it cannot be distinguished from a\n+  branch that just looks \"fully merged\" right after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that another, surviving branch tracks as its upstream is\n+kept, so a branch is never deleted out from under one stacked on top\n+of it. If that kept branch in turn tracks a branch that is being\n+deleted, its now-stale upstream configuration is cleared.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 01c1f64c73..d12a2f57ea 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n \tNULL\n };\n \n@@ -705,6 +707,139 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct spare_data {\n+\tstruct strset *deletable;\n+\tstruct strset *spared;\n+};\n+\n+/*\n+ * A surviving branch stacked on a deletion candidate would lose its\n+ * upstream, so drop that candidate from the delete set and remember it\n+ * in \"spared\" so its own upstream can be tidied up afterwards.\n+ */\n+static int spare_stacked_base(const struct reference *ref, void *cb_data)\n+{\n+\tstruct spare_data *data = cb_data;\n+\tstruct branch *branch;\n+\tconst char *upstream, *up_short;\n+\n+\tif (strset_contains(data->deletable, ref->name))\n+\t\treturn 0;\n+\tbranch = branch_get(ref->name);\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n+\t    !strset_contains(data->deletable, up_short))\n+\t\treturn 0;\n+\n+\tstrset_remove(data->deletable, up_short);\n+\tstrset_add(data->spared, up_short);\n+\treturn 0;\n+}\n+\n+/*\n+ * Keep any branch that a surviving branch tracks as its upstream, so we\n+ * never delete a branch out from under one stacked on top of it.  Such a\n+ * base is itself merged, so when its own upstream is also going away\n+ * (no surviving branch tracks it), clear the base's now-stale upstream.\n+ */\n+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n+{\n+\tstruct strset spared = STRSET_INIT;\n+\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n+\tstruct strbuf key = STRBUF_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n+\n+\tstrset_for_each_entry(&spared, &iter, entry) {\n+\t\tstruct branch *branch = branch_get(entry->key);\n+\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n+\t\tconst char *up_short;\n+\n+\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n+\t\t    !strset_contains(deletable, up_short))\n+\t\t\tcontinue;\n+\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t}\n+\n+\tstrbuf_release(&key);\n+\tstrset_clear(&spared);\n+}\n+\n+static int delete_merged_branches(int argc, const char **argv,\n+\t\t\t\t unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable = STRSET_INIT;\n+\tstruct strvec to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint i, ret = 0;\n+\n+\tif (!argc)\n+\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n+\n+\tfor (i = 0; i < argc; i++)\n+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream, *push;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tpush = branch_get_push(branch, NULL);\n+\t\tif (!push || !strcmp(push, upstream))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(short_name, short_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable, short_name);\n+\t}\n+\n+\tspare_stacked_bases(refs, &deletable);\n+\n+\tstrset_for_each_entry(&deletable, &iter, entry)\n+\t\tstrvec_push(&to_delete, entry->key);\n+\n+\tif (to_delete.nr)\n+\t\tret = delete_branches(to_delete.nr, to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&to_delete);\n+\tstrset_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -746,6 +881,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tint delete_merged = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -799,6 +935,8 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n+\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -846,7 +984,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -856,7 +995,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -898,6 +1037,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged) {\n+\t\tret = delete_merged_branches(argc, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 3104c555f6..047ba54778 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" \"$2\" &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\" &&\n+\t\tgit fetch origin &&\n+\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n+\t)\n+}\n+\n+test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch merged origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit branch --set-upstream-to=origin/next unmerged &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout --detach\n+\t) &&\n+\tsha=$(git -C repo rev-parse --short merged) &&\n+\n+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n+\n+\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\ttest_cmp expect actual &&\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tmain\n+\ttracks-other\n+\tunmerged\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch on-next origin/next &&\n+\tmerged_branch checked-out origin/next &&\n+\tmerged_branch upstream-gone origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b mainline main &&\n+\t\tgit checkout -b on-local mainline &&\n+\t\tgit branch --set-upstream-to=mainline on-local &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit branch --set-upstream-to=origin/main main &&\n+\t\tgit config branch.main.pushRemote origin &&\n+\t\tgit checkout -b tracks-other other/main &&\n+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n+\t\tgit checkout checked-out\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next mainline &&\n+\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tchecked-out\n+\tmain\n+\tmainline\n+\ttracks-other\n+\tupstream-gone\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged requires at least one <branch>' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires at least one <branch>\" err\n+'\n+\n+test_expect_success '--delete-merged keeps a branch that is an upstream' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\tmerged_branch feature origin/next &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\t\tgit branch --set-upstream-to=feature topic &&\n+\t\tgit checkout --detach\n+\t) &&\n+\n+\tgit -C repo branch --dry-run --delete-merged origin/next >out &&\n+\ttest_grep ! \"feature\" out &&\n+\n+\tgit -C repo branch --delete-merged origin/next 2>err &&\n+\n+\ttest_must_be_empty err &&\n+\tgit -C repo rev-parse --verify refs/heads/feature &&\n+\tgit -C repo rev-parse --verify refs/heads/topic &&\n+\techo origin/next >expect &&\n+\tgit -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n+\ttest_cmp expect actual &&\n+\techo feature >expect &&\n+\tgit -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch b3 origin/next &&\n+\t\tgit branch --set-upstream-to=origin/next b3 &&\n+\t\tgit branch b2 origin/next &&\n+\t\tgit branch --set-upstream-to=b3 b2 &&\n+\t\tgit checkout -b b1 b2 &&\n+\t\tgit commit --allow-empty -m \"b1 work\" &&\n+\t\tgit branch --set-upstream-to=b2 b1 &&\n+\t\tgit checkout --detach\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next &&\n+\n+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tb1\n+\tb2\n+\tb3\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch lower origin/next &&\n+\t\tgit branch --set-upstream-to=origin/next lower &&\n+\t\tgit branch mid origin/next &&\n+\t\tgit branch --set-upstream-to=lower mid &&\n+\t\tgit checkout -b tip mid &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\t\tgit branch --set-upstream-to=mid tip &&\n+\t\tgit checkout --detach\n+\t) &&\n+\n+\tgit -C repo branch --delete-merged origin/next lower &&\n+\n+\ttest_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n+\tgit -C repo rev-parse --verify refs/heads/mid &&\n+\ttest_must_fail git -C repo rev-parse mid@{upstream} &&\n+\techo mid >expect &&\n+\tgit -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n+\ttest_cmp expect actual\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"547742","messageId":"8ee46e33-4eb8-4e01-800a-82cc7cefa3f9@gmail.com","threadId":"65574","inReplyTo":"3e29ff17bd703d8333c2d65d36b15c69ddfc2ab9.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 1/7] branch: add --forked filter for --list mode","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T15:18:34Z","receivedAt":"2026-07-10T15:18:44Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> Add a --forked option to \"git branch\" list mode that lists only\n> branches whose configured upstream matches <branch>. The argument\n> can be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n> \"origin\" for the branch its origin/HEAD points at, or a shell glob\n> (e.g. \"origin/*\"), and may be repeated to widen the filter.\n> \n> It is an ordinary list filter, so it combines with the others:\n> \n>      git branch --merged origin/main --forked 'origin/*'\n> \n> lists branches forked from origin that are already merged into\n> origin/main, and --no-merged inverts the question.\n> \n> This is the building block for --delete-merged, which deletes the\n> listed branches once they have landed on their upstream.\n\nThe implementation looks good, I've left a couple of small comments on \nthe tests. One thought I had was whether we want a mode which recurses \nso that if the upstream of topic2 is topic1 which has an upstream of \norigin/main --forked=recurse origin/main would list topic1 and topic2. \nSo long as we don't think that is a sensible default we can add it in \nthe future if we want.\n\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index e7829c2c4b..3104c555f6 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1717,4 +1717,126 @@ test_expect_success 'errors if given a bad branch name' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--forked: setup' '\n> +\ttest_create_repo forked-upstream &&\n> +\t(\n> +\t\tcd forked-upstream &&\n> +\t\ttest_commit base &&\n> +\t\tgit branch one base &&\n> +\t\tgit branch two base\n> +\t) &&\n> +\n> +\ttest_create_repo forked-other &&\n> +\t(\n> +\t\tcd forked-other &&\n> +\t\ttest_commit other-base &&\n> +\t\tgit branch foreign other-base\n> +\t) &&\n> +\n> +\tgit clone forked-upstream forked &&\n> +\t(\n> +\t\tcd forked &&\n> +\t\tgit remote add -f other ../forked-other &&\n> +\t\tgit remote set-head origin one &&\n\nThis is a bit strange because it does not match HEAD in the remote \nrepository but it is necessary for '--forked <remote> uses the branch \n<remote>/HEAD'. I wonder if that test could be written to use main \ninstead but I guess this doesn't do any harm.\n\n> +\t\tgit branch local-base &&\n> +\t\tgit branch --track local-one origin/one &&\n> +\t\tgit branch --track local-two origin/two &&\n> +\t\tgit branch --track local-foreign other/foreign &&\n> +\t\tgit branch --track local-onbase local-base &&\n> +\n> +\t\tgit checkout local-one &&\n> +\t\ttest_commit --no-tag local-one-work local-one.t &&\n> +\t\tgit checkout local-foreign &&\n> +\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n> +\t\tgit checkout --detach\n\nWhy do we need a detached HEAD?\n\n> [...]\n> +test_expect_success '--forked composes with --no-merged' '\n> +\ttest_when_finished \"git -C forked checkout --detach\" &&\n> +\tgit -C forked checkout local-one &&\n> +\ttest_commit -C forked local-only &&\n\nThe branch \"local-one\" already has a local commit so why do we need this?\n\n> +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n> +\t\t--format=\"%(refname:short)\" >actual &&\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked rejects unknown branch/pattern' '\n> +\ttest_must_fail git -C forked branch --forked nope 2>err &&\n> +\ttest_grep \"not a valid branch or pattern\" err\n> +'\n> +\n> +test_expect_success '--forked requires a value' '\n> +\ttest_must_fail git -C forked branch --forked 2>err &&\n> +\ttest_grep \"requires a value\" err\n> +'\n\nIt is a bit odd to have these two tests in the middle of the ones that \ncheck the functionality works.\n\n> +test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n> +\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n> +\techo local-one >expect &&\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--forked narrows a <pattern> argument' '\n> +\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n> +\t\t--format=\"%(refname:short)\" >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tlocal-one\n> +\tlocal-two\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\nThis is looking good\n\nThanks\n\nPhillip\n"},{"id":"547743","messageId":"ed72dd31-5d68-4e78-9123-7061b388ecaa@gmail.com","threadId":"65574","inReplyTo":"cdd4fea4a73e39a1f88127037d806c9b6182d01e.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T15:18:45Z","receivedAt":"2026-07-10T15:18:55Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> \n> -static int delete_branches(int argc, const char **argv, int force, int kinds,\n> -\t\t\t   int quiet)\n> +static int delete_branches(int argc, const char **argv, int kinds,\n> +\t\t\t   unsigned int flags)\n>   {\n>   \tstruct commit *head_rev = NULL;\n>   \tstruct object_id oid;\n> @@ -227,6 +233,8 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \tint i;\n>   \tint ret = 0;\n>   \tint remote_branch = 0;\n> +\tbool force;\n> +\tbool quiet = flags & DELETE_BRANCH_QUIET;\n\nThis means we have two sources of truth because we modify \"flags\" later. \nThe idea of replacing the old function parameters with local variables \nonly works if we're not passing the flags variable on to another \nfunction so I think we should replace all instances of \"force\" and \n\"quiet\" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a \nsingle source of truth and should avoid any future regressions like the \none we saw in an earlier iteration.\n\nThanks\n\nPhillip\n\n>   \tstruct strbuf bname = STRBUF_INIT;\n>   \tenum interpret_branch_kind allowed_interpret;\n>   \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n> @@ -241,7 +249,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\tremote_branch = 1;\n>   \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n>   \n> -\t\tforce = 1;\n> +\t\tflags |= DELETE_BRANCH_FORCE;\n>   \t\tbreak;\n>   \tcase FILTER_REFS_BRANCHES:\n>   \t\tfmt = \"refs/heads/%s\";\n> @@ -252,12 +260,14 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t}\n>   \tbranch_name_pos = strcspn(fmt, \"%\");\n>   \n> +\tforce = flags & DELETE_BRANCH_FORCE;\n> +\n>   \tif (!force)\n>   \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n>   \n>   \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n>   \t\tchar *target = NULL;\n> -\t\tint flags = 0;\n> +\t\tint ref_flags = 0;\n>   \n>   \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n>   \t\tfree(name);\n> @@ -279,7 +289,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\t\t\t     RESOLVE_REF_READING\n>   \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n>   \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n> -\t\t\t\t\t     &oid, &flags);\n> +\t\t\t\t\t     &oid, &ref_flags);\n>   \t\tif (!target) {\n>   \t\t\tif (remote_branch) {\n>   \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n> @@ -291,7 +301,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n>   \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n>   \t\t\t\t\t\t\t\t\t   &oid,\n> -\t\t\t\t\t\t\t\t\t   &flags);\n> +\t\t\t\t\t\t\t\t\t   &ref_flags);\n>   \t\t\t\tFREE_AND_NULL(virtual_name);\n>   \n>   \t\t\t\tif (virtual_target)\n> @@ -306,16 +316,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n>   \t\t\tcontinue;\n>   \t\t}\n>   \n> -\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n> +\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>   \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n> -\t\t\t\t\tforce)) {\n> +\t\t\t\t\tflags)) {\n>   \t\t\tret = 1;\n>   \t\t\tgoto next;\n>   \t\t}\n>   \n>   \t\titem = string_list_append(&refs_to_delete, name);\n> -\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n> -\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n> +\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n> +\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n>   \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n>   \n>   \tnext:\n> @@ -872,7 +882,9 @@ int cmd_branch(int argc,\n>   \tif (delete) {\n>   \t\tif (!argc)\n>   \t\t\tdie(_(\"branch name required\"));\n> -\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n> +\t\tret = delete_branches(argc, argv, filter.kind,\n> +\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n> +\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n>   \t\tgoto out;\n>   \t} else if (show_current) {\n>   \t\tprint_current_branch_name();\n\n"},{"id":"547744","messageId":"97554903-95b2-4b50-9c60-d13edca77e5e@gmail.com","threadId":"65574","inReplyTo":"a0fd5b4a6c1b9d7823b431be703ee1696ea41f6c.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T15:18:57Z","receivedAt":"2026-07-10T15:19:07Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> @@ -235,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int kinds,\n>   \tint remote_branch = 0;\n>   \tbool force;\n>   \tbool quiet = flags & DELETE_BRANCH_QUIET;\n> +\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n\nThe same as the last patch and for the next patch - as we're modifying \nflags lets keep it as the single source of truth.\n\nThanks\n\nPhillip\n\n>   \tstruct strbuf bname = STRBUF_INIT;\n>   \tenum interpret_branch_kind allowed_interpret;\n>   \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n> @@ -319,7 +325,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n>   \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n>   \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n>   \t\t\t\t\tflags)) {\n> -\t\t\tret = 1;\n> +\t\t\tif (!skip_unmerged)\n> +\t\t\t\tret = 1;\n>   \t\t\tgoto next;\n>   \t\t}\n>   \n\n"},{"id":"547745","messageId":"e35f1268-2518-4a44-9c7c-28ba13ca7b7b@gmail.com","threadId":"65574","inReplyTo":"a84c555d99c98abcd4618833c14fa35b191fbda2.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T15:24:25Z","receivedAt":"2026-07-10T15:24:35Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> \tgit branch --delete-merged <branch>...\n\nThis design means that unlike --forked there is no way to limit the \nbranches considered for deletion. I wonder if we'd be better to have \n--delete-merged take an argument like --forked so that the user can \nlimit the branches that might be deleted without resorting to the config \nsetting added in the next patch.\n\n> deletes the local branches that \"--forked <branch>\" would list,\n> keeping only those whose tip is reachable from their configured\n> upstream. The work has already landed on the upstream they track,\n> so the local copy is no longer needed.\n> \n> A branch is not deleted when:\n> \n>    * it is checked out in any worktree\n>    * its upstream remote-tracking branch no longer exists, since a\n>      missing upstream is not by itself a sign of integration\n>    * its push destination equals its upstream (<branch>@{push} is\n>      the same as <branch>@{upstream}), such as a local \"main\" that\n>      tracks and pushes to \"origin/main\". Right after a pull it just\n>      looks \"fully merged\", so it is kept. Only branches that push\n>      somewhere other than their upstream, typically topics in a fork\n>      workflow, are candidates.\n> \n> A branch whose work is not yet merged into its upstream is silently\n> skipped, so one unmerged topic does not abort the whole sweep.\n> \n> A branch that another, surviving branch tracks as its upstream is\n> also kept, so a branch is never deleted out from under one stacked\n> on top of it. Such a kept branch is itself merged, so when its own\n> upstream is being deleted, clear its now-stale upstream config.\n\nThe commit message explains the new feature really well. The \nimplementation looks good, I've left a few questions and comments on the \ntests\n\n> +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n> +{\n> [...]\n> +\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n> +\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n> +\t\tstrbuf_reset(&key);\n> +\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n> +\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n\nIf there are any errors updating the config then the config code will \nprint a message and we continue. As clearing the config is really a \nconvenience feature I think it is fine to ignore errors here.\n\n> +static int delete_merged_branches(int argc, const char **argv,\n> +\t\t\t\t unsigned int flags)\n> [...]\n> +\t\tif (check_branch_commit(short_name, short_name,\n> +\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n> +\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n\nThis check is performed again when we call delete_branches() but we need \nto do it here to prune the branch in order to stop delete_branches() \nprinting an error message. The check involves finding a merge-base so it \nis not necessarily cheap - if that becomes a problem in the future we \ncan add a flag to delete_branches() to skip the check there.\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 3104c555f6..047ba54778 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' '\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--delete-merged: setup' '\n> +\tgit init -b main upstream &&\n> +\t(\n> +\t\tcd upstream &&\n> +\t\ttest_commit base &&\n> +\t\tgit checkout -b next &&\n> +\t\ttest_commit next-work &&\n> +\t\tgit checkout main\n> +\t) &&\n> +\tgit init -b main other &&\n> +\ttest_commit -C other other-base &&\n> +\tgit init -b main fork\n> +'\n> +\n> +setup_repo_for_delete_merged () {\n> +\trm -rf repo &&\n> +\tgit clone upstream repo &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit remote add fork ../fork &&\n> +\t\tgit remote add other ../other &&\n> +\t\tgit config remote.pushDefault fork &&\n> +\t\tgit config push.default current &&\n> +\t\tgit fetch other\n> +\t)\n> +}\n> +\n> +merged_branch () {\n\nA comment would have helped to explain what this helper does. Also \ncalling it create_merged_branch() would be clearer too I think.\n\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b \"$1\" \"$2\" &&\n\nIf we add '--track' we can avoid having to run \"git branch \n--set-upstream-to\" below. The same goes for many if not all of the \nbranches created by \"git checkout -b\" and \"git branch\" in these tests.\n\n> +\t\tgit commit --allow-empty -m \"$1 work\" &&\n> +\t\tgit push origin \"$1:next\" &&\n\nWe let the caller specify the upstream branch, but then always push to \norigin/next - should be be using 'git push ${2%%/*} \"$1:${2#*/}\"', or if \nwe don't need that flexibility hard coding the upstream branch?\n\n> +\t\tgit fetch origin &&\n\nWe've just pushed, what are we fetching here?\n\n> +\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n> +\t)\n> +}\n> +\n> +test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n\nThe first thing setup_repo_for_delete does is delete repo so do we need \nthis as well?\n\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch merged origin/next &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b unmerged origin/next &&\n> +\t\tgit commit --allow-empty -m \"unmerged work\" &&\n\ngood - we have a branch with upstream origin/next that isn't merged and \none that is.\n\n> +\t\tgit branch --set-upstream-to=origin/next unmerged &&\n> +\t\tgit checkout -b tracks-other other/main &&\n> +\t\tgit branch --set-upstream-to=other/main tracks-other &&\n> +\t\tgit checkout --detach\n\nI assume this is to ensure we don't spare a branch because it is checked \nout?\n\n> +\t) &&\n> +\tsha=$(git -C repo rev-parse --short merged) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n> +\n> +\techo \"Deleted branch merged (was $sha).\" >expect &&\n\nThere doesn't seem to be any reason for these command or the ones below \nto be outside the subshell - they're all running commands in \"repo\". \nThat seems to be a common pattern in these tests.\n\n> +\ttest_cmp expect actual &&\n\nThis is good we have two branches with an upstream of origin/next, but \nonly one of them is merged. We also check no other branches are deleted.\n\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tmain\n> +\ttracks-other\n> +\tunmerged\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch on-next origin/next &&\n> +\tmerged_branch checked-out origin/next &&\n> +\tmerged_branch upstream-gone origin/next &&\n\nRight, we create three branches that are all merged into origin/next\n\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b mainline main &&\n> +\t\tgit checkout -b on-local mainline &&\n> +\t\tgit branch --set-upstream-to=mainline on-local &&\n\nWhy do we need on-local to track mainline rather than main? I'm a bit \nconfused what the point of mainline is.\n\n> +\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n> +\t\tgit update-ref -d refs/remotes/origin/topic &&\n\nThese three lines can be replaced by\n\n\tgit config branch.gone.merge does-not-exist\n\n> +\t\tgit branch --set-upstream-to=origin/main main &&\n> +\t\tgit config branch.main.pushRemote origin &&\n\nWhat does this do? Isn't its pushRemote already origin?\n\n> +\t\tgit checkout -b tracks-other other/main &&\n> +\t\tgit branch --set-upstream-to=other/main tracks-other &&\n> +\t\tgit checkout checked-out\n> +\t) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next mainline &&\n\nDo we want to use \"origin/*\" here instead so that we check that main is \nnot deleted because its push destination matches its upstream?\n\n> +\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tchecked-out\n> +\tmain\n> +\tmainline\n> +\ttracks-other\n> +\tupstream-gone\n> +\tEOF\n> +\ttest_cmp expect actual\n\nThis checks we delete on-local - good. I wonder if we should add a \ncomment about the expected outcome so it is clear to the casual reader \nwhat is happening.\n\n> +'\n> +\n> +test_expect_success '--delete-merged requires at least one <branch>' '\n> +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n> +\ttest_grep \"requires at least one <branch>\" err\n> +'\n> +\n> +test_expect_success '--delete-merged keeps a branch that is an upstream' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch feature origin/next &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b topic feature &&\n> +\t\tgit commit --allow-empty -m \"topic work\" &&\n> +\t\tgit branch --set-upstream-to=feature topic &&\n> +\t\tgit checkout --detach\n> +\t) &&\n> +\n> +\tgit -C repo branch --dry-run --delete-merged origin/next >out &&\n\nThis belongs in a later patch and shows that the patches in this series \nhave not been individually tested (c.f. my previous mail about running \n\"git rebase --keep-base --exec\")\n\n> +\ttest_grep ! \"feature\" out &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next 2>err &&\n> +\n> +\ttest_must_be_empty err &&\n\nSo we don't delete anything because feature is needed by topic\n\n> +\tgit -C repo rev-parse --verify refs/heads/feature &&\n> +\tgit -C repo rev-parse --verify refs/heads/topic &&\n\nI preferred the way this as checked in the previous tests with \nfor-each-ref and test_cmp as that shows everything that was kept.\n\n> +\techo origin/next >expect &&\n> +\tgit -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n> +\ttest_cmp expect actual &&\n> +\techo feature >expect &&\n> +\tgit -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n> +\ttest_cmp expect actual\n\nThis is a bit of a faff. Perhaps\n\n     git config --local --get-regexp \n\"branch.(feature|topic).(merge|remote)\" >actual\n\nfollowed by test_cmp would be more concise and more clearly show that \nwe're interested in checking that the config settings still exist.\n\n> +'\n> +\n> +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit branch b3 origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/next b3 &&\n> +\t\tgit branch b2 origin/next &&\n> +\t\tgit branch --set-upstream-to=b3 b2 &&\n> +\t\tgit checkout -b b1 b2 &&\n> +\t\tgit commit --allow-empty -m \"b1 work\" &&\n> +\t\tgit branch --set-upstream-to=b2 b1 &&\n> +\t\tgit checkout --detach\n> +\t) &&\n\nI'd find this easier to follow if the base branch which is created \nfirest was numbered 1, rather than the tip of the stack.\n\n\n> +\tgit -C repo branch --delete-merged origin/next &&\n\nb3 is merged but cannot be deleted because it is the upstream for b2 \nwhich although it is merged into b3 isn't a candidate for deletion \nbecause its upstream is b3.\n\nI'm not quite sure what this test demonstrates that the next one does not.\n\n> +\n> +\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect <<-\\EOF &&\n> +\tb1\n> +\tb2\n> +\tb3\n> +\tmain\n> +\tEOF\n> +\ttest_cmp expect actual\n> +'\n> +\n> +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit branch lower origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/next lower &&\n> +\t\tgit branch mid origin/next &&\n> +\t\tgit branch --set-upstream-to=lower mid &&\n> +\t\tgit checkout -b tip mid &&\n> +\t\tgit commit --allow-empty -m \"tip work\" &&\n> +\t\tgit branch --set-upstream-to=mid tip &&\n> +\t\tgit checkout --detach\n> +\t) &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next lower &&\n\nWe expect lower to be deleted, but not mid because although it is merged \nit is the upstream of an unmerged branch. Again it would be nice to \ncheck that with for-each-ref (maybe that is a common enough pattern to \njustify a helper that takes the expected output on stdin\n\n\tcheck_branches <<-\\EOF\n\tmain\n\tmid\n\ttip\n\tEOF\n\n> +\ttest_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n> +\tgit -C repo rev-parse --verify refs/heads/mid &&\n> +\ttest_must_fail git -C repo rev-parse mid@{upstream} &&\n> +\techo mid >expect &&\n> +\tgit -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n> +\ttest_cmp expect actual\n\nI'd check the config settings here as suggested for the test above. The \ntest coverage looks good, there are just a few places where a comment \nwould help explain what's going on and some places where we can save a \nfew commands.\n\nThanks\n\nPhillip\n\n> +'\n> +\n>   test_done\n\n"},{"id":"547746","messageId":"c1ff2ca9-5744-490b-8d40-b40fc3100718@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-10T15:26:59Z","receivedAt":"2026-07-10T15:27:11Z","isPatch":true,"body":"Hi Harald\n\nI've looked through the first five patches and left a few comments and \nqueries. I'll look at the last two at the start of next week so don't \nre-roll just yet, but my all means reply to the comments, especially \nwhere I've asked questions. So far I like what I've seen.\n\nThanks\n\nPhillip\n\nOn 24/06/2026 22:54, Harald Nordgren via GitGitGadget wrote:\n> Delete branches that have already been merged on upstream.\n> \n> Changes in v18:\n> \n>   * Instead of keeping the whole chain of upstream branches, keep only the\n>     ones an unmerged branch still needs. When a kept (merged) branch in turn\n>     tracks a branch that is being deleted, clear its now-stale upstream\n>     config.\n>   * Rework spare_stacked_bases() to record the kept bases and, in a second\n>     pass, clear the upstream of any whose own base is going away. Build the\n>     to-delete list with strset_for_each_entry() instead of re-walking the\n>     candidate array.\n> \n> Changes in v17:\n> \n>   * Keep a merged branch when another surviving branch still tracks it as its\n>     upstream, so --delete-merged no longer deletes a branch out from under\n>     one stacked on top of it.\n>   * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n>     their own commits.\n> \n> Changes in v16:\n> \n>   * Convert delete_merged_branches() to take an unsigned int flags argument\n>     instead of separate quiet/dry_run booleans, matching delete_branches()\n>   * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n>     single strbuf_release after) instead of allocating and freeing it each\n>     time\n>   * Rewrite the --delete-merged tests as integration tests: branches that\n>     land commits upstream, with deletion and the checked-out, upstream-gone,\n>     and push-equals-upstream safety cases exercised together in one run and\n>     output asserted via test_cmp\n>   * Collapse the many per-aspect test repos into a single reused repo set up\n>     by a setup_repo_for_delete_merged helper, and rename helpers off the old\n>     pm_/prune naming\n>   * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n>     prefixing every command with -C\n> \n> Changes in v15:\n> \n>   * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n>     final, but something to advance the discussion.\n>   * --delete-merged now silently skips not-yet-merged branches instead of\n>     warning.\n>   * Initialized the delete_branches() flag locals where declared. Only force\n>     stays deferred.\n>   * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n>     branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n>     unreachable non-branch ref, and reworked --delete-merged doc wording.\n>   * Broadened the --forked tests (local commits for realism, remote add -f,\n>     --forked coverage), renamed the misleading trunk fixture, and replaced\n>     the misnamed detached branch with git checkout --detach.\n> \n> Changes in v14:\n> \n>   * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n>     remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n>     check_branch_commit() reads, so it wrongly ran the merge check.\n>   * Made flags the single source of truth in delete_branches() so the bit and\n>     the derived locals can't disagree.\n>   * Works locally, but GitHub CI has problems that are there for other\n>     branches too, hopefully not related\n>     (https://github.com/git/git/pull/2285).\n> \n> Changes in v13:\n> \n>   * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n>     instead of a post-pass, so non-matching branches are never allocated.\n>   * Match exact --forked patterns on full refnames (only globs use the\n>     abbreviated upstream), and dropped the old helper machinery, forward\n>     declaration, and string_list in favor of a strvec.\n>   * Replaced the boolean parameters of\n>     delete_branches()/check_branch_commit() with a single unsigned int flags.\n>   * --prune-merged now collects candidates via filter_refs() rather than its\n>     own branch walk.\n>   * --prune-merged now takes its patterns as positional arguments (e.g. git\n>     branch --prune-merged origin/main 'feature*') instead of repeating the\n>     option.\n> \n> Changes in v12:\n> \n>   * Reworked --forked from a standalone action into a --list-mode filter.\n>   * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n>     options.\n>   * Dropped the bare-remote-name resolution for --forked, the argument is now\n>     a ref or a glob.\n> \n> Changes in v11:\n> \n>   * The flags now take a branch, not a remote. --forked and --prune-merged\n>     accept a literal upstream short name like origin/main or a wildmatch\n>     pattern like origin/. The old --all-remotes flag is gone, since origin/\n>     covers that case.\n>   * The prune guard now compares @{push} against @{upstream}. A branch is\n>     spared when these are equal. That is the trunk like case, such as local\n>     main tracking and pushing to origin/main, where \"fully merged to\n>     upstream\" cannot be told apart from \"just pulled\". Only branches that\n>     push somewhere other than their upstream, typically fork based topics,\n>     are candidates. The earlier /HEAD by name guard that the reviewer\n>     rejected is gone.\n>   * New --dry-run for --prune-merged.\n> \n> Changes in v10:\n> \n>   * --forked / --prune-merged now take a branch glob instead of a remote name\n>     — origin, origin/*, origin/release-- all work. This replaces the\n>     remote-only form and subsumes the old --all-remotes flag, which has been\n>     dropped.\n>   * New --dry-run for --prune-merged.\n> \n> Changes in v9:\n> \n>   * --force no longer has special meaning with --prune-merged; reachability\n>     is always enforced. Use git branch -D to delete an unmerged branch.\n>     Matches how git branch's other read/safe actions treat --force.\n>   * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n>   * Dropped the --prune-merged --force tests.\n> \n> Changes in v8:\n> \n>   * Delete only when the branch's work is actually reachable from its\n>     upstream\n>   * Skip branches whose upstream is gone (even with --force)\n>   * Simplified the internal safety flag to live in one place\n> \n> Changes in v7:\n> \n>   * --prune-merged now checks if a branch is merged into its own upstream\n>     first. If the upstream is gone, it checks against the remote's default\n>     branch instead. If neither exists, the branch is refused (use --force to\n>     delete anyway).\n> \n> Changes in v6:\n> \n>   * --prune-merged now measures merged-ness against the remote's default\n>     branch instead of the candidate's upstream — so the decision no longer\n>     depends on which branch happens to be checked out locally.\n>   * delete_branches() / check_branch_commit() gained a per-candidate override\n>     that lets a caller substitute a different \"what counts as merged\"\n>     reference (or skip the check). branch -d callers pass NULL and keep their\n>     existing semantics.\n>   * prune_merged_branches() resolves each candidate's push-remote HEAD and\n>     threads it through, so --prune-merged --all-remotes measures each\n>     candidate against its own remote rather than a single global reference.\n> \n> Changes in v5:\n> \n>   * Drop commit 'fetch: add --prune-merged'\n> \n> Changes in v4:\n> \n>   * Resolve each remote's HEAD and collect the targets into a\n>     protected_default_refs set in collect_forked_set.\n>   * In prune_merged_branches, skip a candidate when its upstream is a\n>     protected default ref and the local branch name matches the default\n>     branch's leaf name (so a local main tracking origin/main is spared, but a\n>     renamed trunk tracking origin/main is not).\n>   * Also skip when the candidate's push ref points at a protected default\n>     ref, so a topic branch configured to push to origin/main is never pruned.\n>   * Tests: spare the local default branch; only protect by matching leaf name\n>     (not by upstream alone); spare a branch whose push ref is the remote\n>     default.\n> \n> Changes in v3:\n> \n>   * s/remote-tracking refs/remote-tracking branches/g\n> \n> Changes in v2:\n> \n>   * The whole feature moved out of git fetch and into git branch. git fetch\n>     --prune-merged now just calls git branch --prune-merged after fetching.\n>   * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n>     options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n>   * New git branch --forked lists local branches whose upstream lives on the\n>     given remote (read-only building block).\n>   * New git branch --prune-merged deletes those branches, but only if their\n>     tip is reachable from the upstream tracking ref; --force skips that\n>     safety check.\n>   * New git branch --all-remotes lets --forked/--prune-merged operate across\n>     every configured remote at once.\n>   * The currently checked-out branch in any worktree is always preserved.\n>   * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n>     topic branch) even with --force; doesn't affect explicit git branch -d.\n>   * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n>     warning per skipped branch instead of the noisy four-line hint that git\n>     branch -d shows.\n>   * New section in git-branch docs; git-fetch docs trimmed to just mention\n>     --prune-merged.\n>   * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n>     shrunk since most logic moved.\n> \n> Harald Nordgren (7):\n>    branch: add --forked filter for --list mode\n>    branch: convert delete_branches() to a flags argument\n>    branch: let delete_branches skip unmerged branches on bulk refusal\n>    branch: prepare delete_branches for a bulk caller\n>    branch: add --delete-merged <branch>\n>    branch: add branch.<name>.deleteMerged opt-out\n>    branch: add --dry-run for --delete-merged\n> \n>   Documentation/config/branch.adoc |   7 +\n>   Documentation/git-branch.adoc    |  48 ++++-\n>   builtin/branch.c                 | 266 +++++++++++++++++++++---\n>   ref-filter.c                     |  70 +++++++\n>   ref-filter.h                     |  10 +\n>   t/t3200-branch.sh                | 342 +++++++++++++++++++++++++++++++\n>   6 files changed, 715 insertions(+), 28 deletions(-)\n> \n> \n> base-commit: ab776a62a78576513ee121424adb19597fbb7613\n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v18\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v18\n> Pull-Request: https://github.com/git/git/pull/2285\n> \n> Range-diff vs v17:\n> \n>   1:  d8cc17bd7f = 1:  3e29ff17bd branch: add --forked filter for --list mode\n>   2:  d14b0403f0 = 2:  cdd4fea4a7 branch: convert delete_branches() to a flags argument\n>   3:  ef2719dac3 = 3:  a0fd5b4a6c branch: let delete_branches skip unmerged branches on bulk refusal\n>   4:  80518f5d11 = 4:  a56d8fe93e branch: prepare delete_branches for a bulk caller\n>   5:  46da7c8140 ! 5:  a84c555d99 branch: add --delete-merged <branch>\n>       @@ Commit message\n>            upstream. The work has already landed on the upstream they track,\n>            so the local copy is no longer needed.\n>        \n>       -    Three kinds of branches are not deleted:\n>       +    A branch is not deleted when:\n>        \n>       -      * any branch checked out in any worktree\n>       -      * any branch whose upstream remote-tracking branch no longer\n>       -        exists, since a missing upstream is not by itself a sign of\n>       -        integration\n>       -      * any branch whose push destination equals its upstream\n>       -        (<branch>@{push} is the same as <branch>@{upstream}), such as\n>       -        a local \"main\" that tracks and pushes to \"origin/main\". Right\n>       -        after a pull it just looks \"fully merged\", so it is kept. Only\n>       -        branches that push somewhere other than their upstream,\n>       -        typically topics in a fork workflow, are candidates.\n>       +      * it is checked out in any worktree\n>       +      * its upstream remote-tracking branch no longer exists, since a\n>       +        missing upstream is not by itself a sign of integration\n>       +      * its push destination equals its upstream (<branch>@{push} is\n>       +        the same as <branch>@{upstream}), such as a local \"main\" that\n>       +        tracks and pushes to \"origin/main\". Right after a pull it just\n>       +        looks \"fully merged\", so it is kept. Only branches that push\n>       +        somewhere other than their upstream, typically topics in a fork\n>       +        workflow, are candidates.\n>        \n>            A branch whose work is not yet merged into its upstream is silently\n>            skipped, so one unmerged topic does not abort the whole sweep.\n>        \n>            A branch that another, surviving branch tracks as its upstream is\n>            also kept, so a branch is never deleted out from under one stacked\n>       -    on top of it. Sparing such a base can in turn protect its own\n>       -    upstream, so the check repeats until the set stops changing.\n>       +    on top of it. Such a kept branch is itself merged, so when its own\n>       +    upstream is being deleted, clear its now-stale upstream config.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n>        +silently skipped. Delete it with `git branch -D` if you want to\n>        +remove it anyway.\n>        ++\n>       -+A branch that another, surviving branch still tracks as its upstream\n>       -+is kept, so a branch is never deleted out from under one stacked on\n>       -+top of it.\n>       ++A branch that another, surviving branch tracks as its upstream is\n>       ++kept, so a branch is never deleted out from under one stacked on top\n>       ++of it. If that kept branch in turn tracks a branch that is being\n>       ++deleted, its now-stale upstream configuration is cleared.\n>        +\n>         `-v`::\n>         `-vv`::\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>         \treturn 0;\n>         }\n>         \n>       -+static int collect_upstream(const struct reference *ref, void *cb_data)\n>       -+{\n>       -+\tstruct string_list *upstreams = cb_data;\n>       -+\tstruct branch *branch = branch_get(ref->name);\n>       -+\tconst char *upstream = branch_get_upstream(branch, NULL);\n>       ++struct spare_data {\n>       ++\tstruct strset *deletable;\n>       ++\tstruct strset *spared;\n>       ++};\n>        +\n>       -+\tstring_list_append(upstreams, ref->name)->util =\n>       -+\t\txstrdup_or_null(upstream);\n>       ++/*\n>       ++ * A surviving branch stacked on a deletion candidate would lose its\n>       ++ * upstream, so drop that candidate from the delete set and remember it\n>       ++ * in \"spared\" so its own upstream can be tidied up afterwards.\n>       ++ */\n>       ++static int spare_stacked_base(const struct reference *ref, void *cb_data)\n>       ++{\n>       ++\tstruct spare_data *data = cb_data;\n>       ++\tstruct branch *branch;\n>       ++\tconst char *upstream, *up_short;\n>       ++\n>       ++\tif (strset_contains(data->deletable, ref->name))\n>       ++\t\treturn 0;\n>       ++\tbranch = branch_get(ref->name);\n>       ++\tupstream = branch_get_upstream(branch, NULL);\n>       ++\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       ++\t    !strset_contains(data->deletable, up_short))\n>       ++\t\treturn 0;\n>       ++\n>       ++\tstrset_remove(data->deletable, up_short);\n>       ++\tstrset_add(data->spared, up_short);\n>        +\treturn 0;\n>        +}\n>        +\n>        +/*\n>       -+ * Keep any branch that another, surviving branch tracks as its\n>       -+ * upstream, so we never delete a branch out from under one stacked on\n>       -+ * top of it.  Sparing a branch makes it a survivor whose own upstream\n>       -+ * then needs the same protection, so repeat until nothing changes.\n>       ++ * Keep any branch that a surviving branch tracks as its upstream, so we\n>       ++ * never delete a branch out from under one stacked on top of it.  Such a\n>       ++ * base is itself merged, so when its own upstream is also going away\n>       ++ * (no surviving branch tracks it), clear the base's now-stale upstream.\n>        + */\n>        +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n>        +{\n>       -+\tstruct string_list upstreams = STRING_LIST_INIT_DUP;\n>       -+\tstruct string_list_item *item;\n>       -+\tbool spared;\n>       -+\n>       -+\trefs_for_each_branch_ref(refs, collect_upstream, &upstreams);\n>       -+\tdo {\n>       -+\t\tspared = false;\n>       -+\t\tfor_each_string_list_item(item, &upstreams) {\n>       -+\t\t\tconst char *up = item->util, *up_short;\n>       -+\n>       -+\t\t\tif (!up || strset_contains(deletable, item->string))\n>       -+\t\t\t\tcontinue;\n>       -+\t\t\tif (!skip_prefix(up, \"refs/heads/\", &up_short) ||\n>       -+\t\t\t    !strset_contains(deletable, up_short))\n>       -+\t\t\t\tcontinue;\n>       -+\n>       -+\t\t\tstrset_remove(deletable, up_short);\n>       -+\t\t\tspared = true;\n>       -+\t\t}\n>       -+\t} while (spared);\n>       -+\n>       -+\tstring_list_clear(&upstreams, 1);\n>       ++\tstruct strset spared = STRSET_INIT;\n>       ++\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n>       ++\tstruct strbuf key = STRBUF_INIT;\n>       ++\tstruct hashmap_iter iter;\n>       ++\tstruct strmap_entry *entry;\n>       ++\n>       ++\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n>       ++\n>       ++\tstrset_for_each_entry(&spared, &iter, entry) {\n>       ++\t\tstruct branch *branch = branch_get(entry->key);\n>       ++\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n>       ++\t\tconst char *up_short;\n>       ++\n>       ++\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       ++\t\t    !strset_contains(deletable, up_short))\n>       ++\t\t\tcontinue;\n>       ++\n>       ++\t\tstrbuf_reset(&key);\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n>       ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       ++\t\tstrbuf_reset(&key);\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n>       ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       ++\t}\n>       ++\n>       ++\tstrbuf_release(&key);\n>       ++\tstrset_clear(&spared);\n>        +}\n>        +\n>        +static int delete_merged_branches(int argc, const char **argv,\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\tstruct ref_array candidates = { 0 };\n>        +\tstruct strset deletable = STRSET_INIT;\n>        +\tstruct strvec to_delete = STRVEC_INIT;\n>       ++\tstruct hashmap_iter iter;\n>       ++\tstruct strmap_entry *entry;\n>        +\tint i, ret = 0;\n>        +\n>        +\tif (!argc)\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\n>        +\tspare_stacked_bases(refs, &deletable);\n>        +\n>       -+\tfor (i = 0; i < candidates.nr; i++) {\n>       -+\t\tconst char *short_name;\n>       -+\n>       -+\t\tif (skip_prefix(candidates.items[i]->refname, \"refs/heads/\",\n>       -+\t\t\t\t&short_name) &&\n>       -+\t\t    strset_contains(&deletable, short_name))\n>       -+\t\t\tstrvec_push(&to_delete, short_name);\n>       -+\t}\n>       ++\tstrset_for_each_entry(&deletable, &iter, entry)\n>       ++\t\tstrvec_push(&to_delete, entry->key);\n>        +\n>        +\tif (to_delete.nr)\n>        +\t\tret = delete_branches(to_delete.nr, to_delete.v,\n>       @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n>        +\t\tgit checkout --detach\n>        +\t) &&\n>        +\n>       ++\tgit -C repo branch --dry-run --delete-merged origin/next >out &&\n>       ++\ttest_grep ! \"feature\" out &&\n>       ++\n>        +\tgit -C repo branch --delete-merged origin/next 2>err &&\n>        +\n>        +\ttest_must_be_empty err &&\n>        +\tgit -C repo rev-parse --verify refs/heads/feature &&\n>       -+\tgit -C repo rev-parse --verify refs/heads/topic\n>       ++\tgit -C repo rev-parse --verify refs/heads/topic &&\n>       ++\techo origin/next >expect &&\n>       ++\tgit -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n>       ++\ttest_cmp expect actual &&\n>       ++\techo feature >expect &&\n>       ++\tgit -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n>       ++\ttest_cmp expect actual\n>        +'\n>        +\n>        +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n>       @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n>        +\tEOF\n>        +\ttest_cmp expect actual\n>        +'\n>       ++\n>       ++test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n>       ++\ttest_when_finished \"rm -rf repo\" &&\n>       ++\tsetup_repo_for_delete_merged &&\n>       ++\t(\n>       ++\t\tcd repo &&\n>       ++\t\tgit branch lower origin/next &&\n>       ++\t\tgit branch --set-upstream-to=origin/next lower &&\n>       ++\t\tgit branch mid origin/next &&\n>       ++\t\tgit branch --set-upstream-to=lower mid &&\n>       ++\t\tgit checkout -b tip mid &&\n>       ++\t\tgit commit --allow-empty -m \"tip work\" &&\n>       ++\t\tgit branch --set-upstream-to=mid tip &&\n>       ++\t\tgit checkout --detach\n>       ++\t) &&\n>       ++\n>       ++\tgit -C repo branch --delete-merged origin/next lower &&\n>       ++\n>       ++\ttest_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n>       ++\tgit -C repo rev-parse --verify refs/heads/mid &&\n>       ++\ttest_must_fail git -C repo rev-parse mid@{upstream} &&\n>       ++\techo mid >expect &&\n>       ++\tgit -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n>       ++\ttest_cmp expect actual\n>       ++'\n>        +\n>         test_done\n>   6:  27903fbb1d ! 6:  d52d717b70 branch: add branch.<name>.deleteMerged opt-out\n>       @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n>         \tstruct strset deletable = STRSET_INIT;\n>         \tstruct strvec to_delete = STRVEC_INIT;\n>        +\tstruct strbuf key = STRBUF_INIT;\n>       + \tstruct hashmap_iter iter;\n>       + \tstruct strmap_entry *entry;\n>        +\tbool quiet = flags & DELETE_BRANCH_QUIET;\n>         \tint i, ret = 0;\n>         \n>       @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n>         \tref_array_clear(&candidates);\n>        \n>         ## t/t3200-branch.sh ##\n>       -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch\n>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow\n>         \ttest_cmp expect actual\n>         '\n>         \n>   7:  49c1bcf1fb ! 7:  8d0323f4b3 branch: add --dry-run for --delete-merged\n>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: A branch that another, surviving branch still tracks as its upstream\n>       - is kept, so a branch is never deleted out from under one stacked on\n>       - top of it.\n>       +@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top\n>       + of it. If that kept branch in turn tracks a branch that is being\n>       + deleted, its now-stale upstream configuration is cleared.\n>         \n>        +`--dry-run`::\n>        +\tWith `--delete-merged`, print which branches would be\n> \n\n"},{"id":"547780","messageId":"CAHwyqnV6kS8ZmfXm+9JttoJ=kWP4kYdLGX2giASVMSRXVKL-Pw@mail.gmail.com","threadId":"65574","inReplyTo":"ed72dd31-5d68-4e78-9123-7061b388ecaa@gmail.com","subject":"Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-10T17:25:24Z","receivedAt":"2026-07-10T17:26:03Z","isPatch":true,"body":"> This means we have two sources of truth because we modify \"flags\" later.\n> The idea of replacing the old function parameters with local variables\n> only works if we're not passing the flags variable on to another\n> function so I think we should replace all instances of \"force\" and\n> \"quiet\" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a\n> single source of truth and should avoid any future regressions like the\n> one we saw in an earlier iteration.\n\nYes, it makes sense.\n\nI just hope we remember this discussion, so another reviewer doesn't\npush in the other direction later because it seems like low-hanging\nfruit.\n\n\nHarald\n"},{"id":"547817","messageId":"CAHwyqnXq=gk=hKUWbFHWLZFKCTPTWqHRv=tH0BC3HaetoRG5Aw@mail.gmail.com","threadId":"65574","inReplyTo":"8ee46e33-4eb8-4e01-800a-82cc7cefa3f9@gmail.com","subject":"Re: [PATCH v18 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-11T12:30:56Z","receivedAt":"2026-07-11T12:31:36Z","isPatch":true,"body":"> The implementation looks good, I've left a couple of small comments on\n> the tests.\n\nThanks for all the help so far! Much appreciated!\n\n> One thought I had was whether we want a mode which recurses\n> so that if the upstream of topic2 is topic1 which has an upstream of\n> origin/main --forked=recurse origin/main would list topic1 and topic2.\n> So long as we don't think that is a sensible default we can add it in\n> the future if we want.\n\nI don't understand this one, but if we safely do it later then that\nsounds good considering we will soon be on v19.\n\n> > +             git branch local-base &&\n> > +             git branch --track local-one origin/one &&\n> > +             git branch --track local-two origin/two &&\n> > +             git branch --track local-foreign other/foreign &&\n> > +             git branch --track local-onbase local-base &&\n> > +\n> > +             git checkout local-one &&\n> > +             test_commit --no-tag local-one-work local-one.t &&\n> > +             git checkout local-foreign &&\n> > +             test_commit --no-tag local-foreign-work local-foreign.t &&\n> > +             git checkout --detach\n>\n> Why do we need a detached HEAD?\n\nThe '--delete-merged honours branch.<name>.deleteMerged=false' ' and\n\"branch -d still deletes a deleteMerged=false branch\" ' tests need it,\nbut it's not necessary here, deleting.\n\n> > +     git -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n> > +             --format=\"%(refname:short)\" >actual &&\n> > +     echo local-one >expect &&\n> > +     test_cmp expect actual\n> > +'\n> > +\n> > +test_expect_success '--forked rejects unknown branch/pattern' '\n> > +     test_must_fail git -C forked branch --forked nope 2>err &&\n> > +     test_grep \"not a valid branch or pattern\" err\n> > +'\n> > +\n> > +test_expect_success '--forked requires a value' '\n> > +     test_must_fail git -C forked branch --forked 2>err &&\n> > +     test_grep \"requires a value\" err\n> > +'\n>\n> It is a bit odd to have these two tests in the middle of the ones that\n> check the functionality works.\n\nGood point, reordering.\n\n\nHarald\n"},{"id":"547818","messageId":"CAHwyqnU0ifHu0+GfMR9GqWKgFrTOyQn-FbUH0wTm_07nCa26tA@mail.gmail.com","threadId":"65574","inReplyTo":"e35f1268-2518-4a44-9c7c-28ba13ca7b7b@gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-11T13:08:05Z","receivedAt":"2026-07-11T13:08:44Z","isPatch":true,"body":"> >       git branch --delete-merged <branch>...\n>\n> This design means that unlike --forked there is no way to limit the\n> branches considered for deletion. I wonder if we'd be better to have\n> --delete-merged take an argument like --forked so that the user can\n> limit the branches that might be deleted without resorting to the config\n> setting added in the next patch.\n\nMakes sense, I updated it now and it seems to work fine.\n\n> > diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> > index 3104c555f6..047ba54778 100755\n> > --- a/t/t3200-branch.sh\n> > +++ b/t/t3200-branch.sh\n> > @@ -1839,4 +1839,189 @@ test_expect_success '--forked narrows a <pattern> argument' '\n> >       test_cmp expect actual\n> >   '\n> >\n> > +test_expect_success '--delete-merged: setup' '\n> > +     git init -b main upstream &&\n> > +     (\n> > +             cd upstream &&\n> > +             test_commit base &&\n> > +             git checkout -b next &&\n> > +             test_commit next-work &&\n> > +             git checkout main\n> > +     ) &&\n> > +     git init -b main other &&\n> > +     test_commit -C other other-base &&\n> > +     git init -b main fork\n> > +'\n> > +\n> > +setup_repo_for_delete_merged () {\n> > +     rm -rf repo &&\n> > +     git clone upstream repo &&\n> > +     (\n> > +             cd repo &&\n> > +             git remote add fork ../fork &&\n> > +             git remote add other ../other &&\n> > +             git config remote.pushDefault fork &&\n> > +             git config push.default current &&\n> > +             git fetch other\n> > +     )\n> > +}\n> > +\n> > +merged_branch () {\n>\n> A comment would have helped to explain what this helper does. Also\n> calling it create_merged_branch() would be clearer too I think.\n\nI want to avoid comments because of the maintenance burden they\nintroduce, I think 'create_merged_branch' will be a very clear name.\nThanks for that suggestion!\n\n> > +     (\n> > +             cd repo &&\n> > +             git checkout -b \"$1\" \"$2\" &&\n>\n> If we add '--track' we can avoid having to run \"git branch\n> --set-upstream-to\" below. The same goes for many if not all of the\n> branches created by \"git checkout -b\" and \"git branch\" in these tests.\n\nThat's a very good point. I wanted to keep it \"vanilla\" since I worked\na lot on upstreams and tracking branches in my other topics and didn't\nwant it to bleed over, but yes it's much nicer.\n\n> > +             git commit --allow-empty -m \"$1 work\" &&\n> > +             git push origin \"$1:next\" &&\n>\n> We let the caller specify the upstream branch, but then always push to\n> origin/next - should be be using 'git push ${2%%/*} \"$1:${2#*/}\"', or if\n> we don't need that flexibility hard coding the upstream branch?\n\nI'll hardcode for now.\n\n> > +             git fetch origin &&\n>\n> We've just pushed, what are we fetching here?\n>\n> > +             git branch --set-upstream-to=\"$2\" \"$1\"\n> > +     )\n> > +}\n> > +\n> > +test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n> > +     test_when_finished \"rm -rf repo\" &&\n>\n> The first thing setup_repo_for_delete does is delete repo so do we need\n> this as well?\n\nNope, let's rip it out.\n\n> > +             git branch --set-upstream-to=origin/next unmerged &&\n> > +             git checkout -b tracks-other other/main &&\n> > +             git branch --set-upstream-to=other/main tracks-other &&\n> > +             git checkout --detach\n>\n> I assume this is to ensure we don't spare a branch because it is checked\n> out?\n\nYes, but it's also a pattern that I copy-pasted a few too many times,\nsee my other comment where some of these can be removed.\n\n> > +     ) &&\n> > +     sha=$(git -C repo rev-parse --short merged) &&\n> > +\n> > +     git -C repo branch --delete-merged origin/next >actual 2>&1 &&\n> > +\n> > +     echo \"Deleted branch merged (was $sha).\" >expect &&\n>\n> There doesn't seem to be any reason for these command or the ones below\n> to be outside the subshell - they're all running commands in \"repo\".\n> That seems to be a common pattern in these tests.\n\nAgreed, but other reviewers were against subshells before. I updated\nit now and looks much cleaner.\n\n> > +     (\n> > +             cd repo &&\n> > +             git checkout -b mainline main &&\n> > +             git checkout -b on-local mainline &&\n> > +             git branch --set-upstream-to=mainline on-local &&\n>\n> Why do we need on-local to track mainline rather than main? I'm a bit\n> confused what the point of mainline is.\n\nIt's to have an indirection of a branch that is the same as main but\nwill be protected. I tried to delete it now and replace it with just\nmain, but then main was deleted and subsequent tests failed.\n\n> > +             git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n> > +             git branch --set-upstream-to=origin/topic upstream-gone &&\n> > +             git update-ref -d refs/remotes/origin/topic &&\n>\n> These three lines can be replaced by\n>\n>         git config branch.gone.merge does-not-exist\n>\n> > +             git branch --set-upstream-to=origin/main main &&\n> > +             git config branch.main.pushRemote origin &&\n>\n> What does this do? Isn't its pushRemote already origin?\n\nDeleted.\n\n> > +             git checkout -b tracks-other other/main &&\n> > +             git branch --set-upstream-to=other/main tracks-other &&\n> > +             git checkout checked-out\n> > +     ) &&\n> > +\n> > +     git -C repo branch --delete-merged origin/next mainline &&\n>\n> Do we want to use \"origin/*\" here instead so that we check that main is\n> not deleted because its push destination matches its upstream?\n\nThat's a good point and a good regression test later on.\n\n> > +\n> > +     git -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> > +     cat >expect <<-\\EOF &&\n> > +     checked-out\n> > +     main\n> > +     mainline\n> > +     tracks-other\n> > +     upstream-gone\n> > +     EOF\n> > +     test_cmp expect actual\n>\n> This checks we delete on-local - good. I wonder if we should add a\n> comment about the expected outcome so it is clear to the casual reader\n> what is happening.\n\nI'll rename to 'local-to-delete' to clarify and to avoid the comment.\n\n> > +'\n> > +\n> > +test_expect_success '--delete-merged requires at least one <branch>' '\n> > +     test_must_fail git -C forked branch --delete-merged 2>err &&\n> > +     test_grep \"requires at least one <branch>\" err\n> > +'\n> > +\n> > +test_expect_success '--delete-merged keeps a branch that is an upstream' '\n> > +     test_when_finished \"rm -rf repo\" &&\n> > +     setup_repo_for_delete_merged &&\n> > +     merged_branch feature origin/next &&\n> > +     (\n> > +             cd repo &&\n> > +             git checkout -b topic feature &&\n> > +             git commit --allow-empty -m \"topic work\" &&\n> > +             git branch --set-upstream-to=feature topic &&\n> > +             git checkout --detach\n> > +     ) &&\n> > +\n> > +     git -C repo branch --dry-run --delete-merged origin/next >out &&\n>\n> This belongs in a later patch and shows that the patches in this series\n> have not been individually tested (c.f. my previous mail about running\n> \"git rebase --keep-base --exec\")\n\nYup, should be fixed now. And I usually run \"git rebase --keep-base\n--exec\" a lot!\n\n> > +     git -C repo rev-parse --verify refs/heads/feature &&\n> > +     git -C repo rev-parse --verify refs/heads/topic &&\n>\n> I preferred the way this as checked in the previous tests with\n> for-each-ref and test_cmp as that shows everything that was kept.\n\nYes, it's better.\n\n> > +     echo origin/next >expect &&\n> > +     git -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n> > +     test_cmp expect actual &&\n> > +     echo feature >expect &&\n> > +     git -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n> > +     test_cmp expect actual\n>\n> This is a bit of a faff. Perhaps\n>\n>      git config --local --get-regexp\n> \"branch.(feature|topic).(merge|remote)\" >actual\n>\n> followed by test_cmp would be more concise and more clearly show that\n> we're interested in checking that the config settings still exist.\n\nNice.\n\n> > +'\n> > +\n> > +test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n> > +     test_when_finished \"rm -rf repo\" &&\n> > +     setup_repo_for_delete_merged &&\n> > +     (\n> > +             cd repo &&\n> > +             git branch b3 origin/next &&\n> > +             git branch --set-upstream-to=origin/next b3 &&\n> > +             git branch b2 origin/next &&\n> > +             git branch --set-upstream-to=b3 b2 &&\n> > +             git checkout -b b1 b2 &&\n> > +             git commit --allow-empty -m \"b1 work\" &&\n> > +             git branch --set-upstream-to=b2 b1 &&\n> > +             git checkout --detach\n> > +     ) &&\n>\n> I'd find this easier to follow if the base branch which is created\n> first was numbered 1, rather than the tip of the stack.\n>\n>\n> > +     git -C repo branch --delete-merged origin/next &&\n>\n> b3 is merged but cannot be deleted because it is the upstream for b2\n> which although it is merged into b3 isn't a candidate for deletion\n> because its upstream is b3.\n>\n> I'm not quite sure what this test demonstrates that the next one does not.\n\nDeleting this one, which also removes the question of b1, b2 and b3 naming.\n\n> > +\n> > +     git -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> > +     cat >expect <<-\\EOF &&\n> > +     b1\n> > +     b2\n> > +     b3\n> > +     main\n> > +     EOF\n> > +     test_cmp expect actual\n> > +'\n> > +\n> > +test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n> > +     test_when_finished \"rm -rf repo\" &&\n> > +     setup_repo_for_delete_merged &&\n> > +     (\n> > +             cd repo &&\n> > +             git branch lower origin/next &&\n> > +             git branch --set-upstream-to=origin/next lower &&\n> > +             git branch mid origin/next &&\n> > +             git branch --set-upstream-to=lower mid &&\n> > +             git checkout -b tip mid &&\n> > +             git commit --allow-empty -m \"tip work\" &&\n> > +             git branch --set-upstream-to=mid tip &&\n> > +             git checkout --detach\n> > +     ) &&\n> > +\n> > +     git -C repo branch --delete-merged origin/next lower &&\n>\n> We expect lower to be deleted, but not mid because although it is merged\n> it is the upstream of an unmerged branch. Again it would be nice to\n> check that with for-each-ref (maybe that is a common enough pattern to\n> justify a helper that takes the expected output on stdin\n>\n>         check_branches <<-\\EOF\n>         main\n>         mid\n>         tip\n>         EOF\n\nVery good idea! Created the helper!\n\n> > +     test_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n> > +     git -C repo rev-parse --verify refs/heads/mid &&\n> > +     test_must_fail git -C repo rev-parse mid@{upstream} &&\n> > +     echo mid >expect &&\n> > +     git -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n> > +     test_cmp expect actual\n>\n> I'd check the config settings here as suggested for the test above. The\n> test coverage looks good, there are just a few places where a comment\n> would help explain what's going on and some places where we can save a\n> few commands.\n\nWill update! Thanks again!\n\n\nHarald\n"},{"id":"547858","messageId":"CAHwyqnWspUTSnqmkMyXtWuAnENDSzrRLhhUR=Ljtt1xer3tphA@mail.gmail.com","threadId":"65574","inReplyTo":"CAHwyqnU0ifHu0+GfMR9GqWKgFrTOyQn-FbUH0wTm_07nCa26tA@mail.gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-11T19:36:35Z","receivedAt":"2026-07-11T19:37:13Z","isPatch":true,"body":"> > > +     (\n> > > +             cd repo &&\n> > > +             git checkout -b mainline main &&\n> > > +             git checkout -b on-local mainline &&\n> > > +             git branch --set-upstream-to=mainline on-local &&\n> >\n> > Why do we need on-local to track mainline rather than main? I'm a bit\n> > confused what the point of mainline is.\n>\n> It's to have an indirection of a branch that is the same as main but\n> will be protected. I tried to delete it now and replace it with just\n> main, but then main was deleted and subsequent tests failed.\n\nDigging more into this, probably the most elegant solution is to\nreplace mainline with main, but then also do this:\n\n    git config branch.main.pushRemote origin\n\nThis exposes something that I don't love about this feature, which is\nthat when using a pushDefault (like we do in the tests with 'git\nconfig remote.pushDefault fork') if not adding a special case for the\nmain/master branch (like 'git config branch.main.pushRemote origin'),\nthen it will get cleaned up as a forked branch.\n\nBut there was a lot of discussion about this already, so I won't get\ninto this again.\n\n\nHarald\n"},{"id":"548010","messageId":"278df7ad-caf1-40fa-b5f9-34d78f435fd0@gmail.com","threadId":"65574","inReplyTo":"8d0323f4b30cdfed134ff2840cc8a9ab32f9db53.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 7/7] branch: add --dry-run for --delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T15:27:29Z","receivedAt":"2026-07-13T15:27:36Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n> With --dry-run, --delete-merged prints the local branches it would\n> delete, one \"Would delete branch <name>\" line each, and exits\n> without touching any ref. The same filtering applies, so the output\n> is exactly the set that the real run would delete.\n\nThe same filtering as what? I think something like\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that \nwould be deleted without modifing any refs.\n\nwould be sufficient\n> --dry-run is only meaningful together with --delete-merged and is\n> rejected otherwise.\n\nGood\n> @@ -346,13 +348,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n>   \t\tfree(target);\n>   \t}\n>   \n> -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n> +\tif (!dry_run &&\n> +\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n>   \t\tret = 1;\n>   \n>   \tfor_each_string_list_item(item, &refs_to_delete) {\n>   \t\tchar *describe_ref = item->util;\n>   \t\tchar *name = item->string;\n> -\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n> +\t\tif (dry_run) {\n> +\t\t\tif (!quiet)\n\nThis matches what we do without '--dry-run' but what use is '--dry-run \n--quiet' if it does not print anything?\n\n> +\t\t\t\tprintf(remote_branch\n> +\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n> +\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n> +\t\t\t\t\tname + branch_name_pos, describe_ref);\n> +\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index b7595610d9..cddcde341d 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1892,8 +1892,12 @@ test_expect_success '--delete-merged deletes merged branches and spares the rest\n>   \t) &&\n>   \tsha=$(git -C repo rev-parse --short merged) &&\n>   \n> -\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n> +\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n> +\techo \"Would delete branch merged (was $sha).\" >expect &&\n> +\ttest_cmp expect actual &&\n> +\tgit -C repo rev-parse --verify refs/heads/merged &&\n>   \n> +\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n\nI was wondering why the diff shows the line above being deleted and then \nadded, it is because previously there was a blank line after it. The \ntest for --dry-run looks good.\n>   \techo \"Deleted branch merged (was $sha).\" >expect &&\n>   \ttest_cmp expect actual &&\n>   \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> @@ -2050,4 +2054,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n>   \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n>   '\n>   \n> +test_expect_success '--dry-run without --delete-merged is rejected' '\n> +\ttest_must_fail git -C forked branch --dry-run 2>err &&\n> +\ttest_grep \"requires --delete-merged\" err\n\nNice\n\nThanks\n\nPhillip\n"},{"id":"548011","messageId":"f53f7140-b9c0-40dd-8bd3-89917a4eb2a2@gmail.com","threadId":"65574","inReplyTo":"d52d717b70c80b668e6d3a1fdf186ab4846664c7.1782338106.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T15:27:38Z","receivedAt":"2026-07-13T15:27:44Z","isPatch":true,"body":"Hi Harald\n\nOn 24/06/2026 22:55, Harald Nordgren via GitGitGadget wrote:\n> \n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 047ba54778..b7595610d9 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -2024,4 +2024,30 @@ test_expect_success '--delete-merged clears the upstream of a kept base whose ow\n>   \ttest_cmp expect actual\n>   '\n>   \n> +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch deleted origin/next &&\n> +\tmerged_branch kept origin/next &&\n> +\tgit -C repo config branch.kept.deleteMerged false &&\n> +\tgit -C repo checkout --detach &&\n> +\n> +\tgit -C repo branch --delete-merged origin/next 2>err &&\n> +\n> +\ttest_grep \"Skipping .kept.\" err &&\n> +\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n> +\tgit -C repo rev-parse --verify refs/heads/kept\n\nAs with the previous patches, I think this would be nicer if we checked \nthe output for for-each-ref. Everything else looks fine.\n\nThanks\n\nPhillip\n\n> +'\n> +\n> +test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n> +\ttest_when_finished \"rm -rf repo\" &&\n> +\tsetup_repo_for_delete_merged &&\n> +\tmerged_branch kept origin/next &&\n> +\tgit -C repo config branch.kept.deleteMerged false &&\n> +\tgit -C repo checkout --detach &&\n> +\n> +\tgit -C repo branch -d kept &&\n> +\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n> +'\n> +\n>   test_done\n\n"},{"id":"548012","messageId":"dd92c389-ba8e-4704-8464-1fcc274bb05a@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnV6kS8ZmfXm+9JttoJ=kWP4kYdLGX2giASVMSRXVKL-Pw@mail.gmail.com","subject":"Re: [PATCH v18 2/7] branch: convert delete_branches() to a flags argument","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T15:28:00Z","receivedAt":"2026-07-13T15:28:08Z","isPatch":true,"body":"Hi Harald\n\nOn 10/07/2026 18:25, Harald Nordgren wrote:\n>> This means we have two sources of truth because we modify \"flags\" later.\n>> The idea of replacing the old function parameters with local variables\n>> only works if we're not passing the flags variable on to another\n>> function so I think we should replace all instances of \"force\" and\n>> \"quiet\" with flags & DELETE_BRANCH_FORCE/QUIET. That way we have a\n>> single source of truth and should avoid any future regressions like the\n>> one we saw in an earlier iteration.\n> \n> Yes, it makes sense.\n> \n> I just hope we remember this discussion, so another reviewer doesn't\n> push in the other direction later because it seems like low-hanging\n> fruit.\n\nIf you explain why we're using flags as the source of truth, rather than \nreplacing the function parameters with local variables in this function \nthat should not be a problem.\n\nThanks\n\nPhillip\n"},{"id":"548013","messageId":"5212d968-6121-466c-8225-36b4bad6b211@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnWspUTSnqmkMyXtWuAnENDSzrRLhhUR=Ljtt1xer3tphA@mail.gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T15:39:52Z","receivedAt":"2026-07-13T15:39:59Z","isPatch":true,"body":"On 11/07/2026 20:36, Harald Nordgren wrote:\n>>>> +     (\n>>>> +             cd repo &&\n>>>> +             git checkout -b mainline main &&\n>>>> +             git checkout -b on-local mainline &&\n>>>> +             git branch --set-upstream-to=mainline on-local &&\n>>>\n>>> Why do we need on-local to track mainline rather than main? I'm a bit\n>>> confused what the point of mainline is.\n>>\n>> It's to have an indirection of a branch that is the same as main but\n>> will be protected. I tried to delete it now and replace it with just\n>> main, but then main was deleted and subsequent tests failed.\n> \n> Digging more into this, probably the most elegant solution is to\n> replace mainline with main, but then also do this:\n> \n>      git config branch.main.pushRemote origin\n> \n> This exposes something that I don't love about this feature,\n\nby \"this feature\" do you mean \"git branch --delete-merged\"?\n\n> which is\n> that when using a pushDefault (like we do in the tests with 'git\n> config remote.pushDefault fork') if not adding a special case for the\n> main/master branch (like 'git config branch.main.pushRemote origin'),\n> then it will get cleaned up as a forked branch.\n\nOh, so because the default push remote is not \"origin\" we need to \noverride that for the branches that we do push to \"origin\". That's a \npain, but even if we did add a special case for the default branch, it \nwould not protect other branches like \"next\" and \"seen\".\n\nWe should maybe add a note to the documentation about that to warn users.\n> But there was a lot of discussion about this already, so I won't get\n> into this again.\n\nFair enough\n\nThanks\n\nPhillip\n\n"},{"id":"548017","messageId":"CAHwyqnWFzdH8e_Z+ZLpv7GYQS3TSwjBmMCRf6Csk841aK7+gbw@mail.gmail.com","threadId":"65574","inReplyTo":"278df7ad-caf1-40fa-b5f9-34d78f435fd0@gmail.com","subject":"Re: [PATCH v18 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-13T16:23:55Z","receivedAt":"2026-07-13T16:24:33Z","isPatch":true,"body":"> This matches what we do without '--dry-run' but what use is '--dry-run\n> --quiet' if it does not print anything?\n\nYeah, it's a bit weird. What do you suggest we do instead, reject the\ncombination or turn it into a no-op?\n\n\nHarald\n"},{"id":"548021","messageId":"CAHwyqnVSfZmCm51DcMLR0HHBk=vnZR8mQrXRW4pVvXh4==Md3w@mail.gmail.com","threadId":"65574","inReplyTo":"f53f7140-b9c0-40dd-8bd3-89917a4eb2a2@gmail.com","subject":"Re: [PATCH v18 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-13T16:33:19Z","receivedAt":"2026-07-13T16:33:58Z","isPatch":true,"body":"> > +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n> > +     test_when_finished \"rm -rf repo\" &&\n> > +     setup_repo_for_delete_merged &&\n> > +     merged_branch deleted origin/next &&\n> > +     merged_branch kept origin/next &&\n> > +     git -C repo config branch.kept.deleteMerged false &&\n> > +     git -C repo checkout --detach &&\n> > +\n> > +     git -C repo branch --delete-merged origin/next 2>err &&\n> > +\n> > +     test_grep \"Skipping .kept.\" err &&\n> > +     test_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n> > +     git -C repo rev-parse --verify refs/heads/kept\n>\n> As with the previous patches, I think this would be nicer if we checked\n> the output for for-each-ref. Everything else looks fine.\n\nThanks! Yes, I have already created a helper for it, and it makes it a\nlot clearer. Thanks again for a good suggestion.\n\n\nHarald\n"},{"id":"548024","messageId":"279e6d69-191b-437a-b1b1-ecd879343f3d@gmail.com","threadId":"65574","inReplyTo":"5212d968-6121-466c-8225-36b4bad6b211@gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-13T16:39:56Z","receivedAt":"2026-07-13T16:39:54Z","isPatch":true,"body":"Hi Harald\n\nOn 13/07/2026 16:39, Phillip Wood wrote:\n> On 11/07/2026 20:36, Harald Nordgren wrote:\n>>\n>> Digging more into this, probably the most elegant solution is to\n>> replace mainline with main, but then also do this:\n>>\n>>      git config branch.main.pushRemote origin\n>>\n>> This exposes something that I don't love about this feature,\n> \n> by \"this feature\" do you mean \"git branch --delete-merged\"?\n> \n>> which is\n>> that when using a pushDefault (like we do in the tests with 'git\n>> config remote.pushDefault fork') if not adding a special case for the\n>> main/master branch (like 'git config branch.main.pushRemote origin'),\n>> then it will get cleaned up as a forked branch.\n> \n> Oh, so because the default push remote is not \"origin\" we need to \n> override that for the branches that we do push to \"origin\". That's a \n> pain, but even if we did add a special case for the default branch, it \n> would not protect other branches like \"next\" and \"seen\".\n\nThinking about this a bit more, rather than protecting branches where \n$branch@{push} == $branch@{upstream}, perhaps we should be protecting \nbranches that are merged into their upstream but\n\n     git push branch.$branch.remote $branch\n\nwould update $branch@{upstream}. So we'd apply the push refspec to the \nbranch name, then apply the fetch refspec to that and check the result \ndid not match the name of the upstream branch.\n\nDoes that make sense?\n\nThanks\n\nPhillip\n\n"},{"id":"548034","messageId":"CAHwyqnUKSD=G1jkdc6n5mVA-NvCgL+c9zCEW_Lr8OBP5q2fKkw@mail.gmail.com","threadId":"65574","inReplyTo":"279e6d69-191b-437a-b1b1-ecd879343f3d@gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-13T18:17:00Z","receivedAt":"2026-07-13T18:17:38Z","isPatch":true,"body":"> >> This exposes something that I don't love about this feature,\n> >\n> > by \"this feature\" do you mean \"git branch --delete-merged\"?\n> >\n> >> which is\n> >> that when using a pushDefault (like we do in the tests with 'git\n> >> config remote.pushDefault fork') if not adding a special case for the\n> >> main/master branch (like 'git config branch.main.pushRemote origin'),\n> >> then it will get cleaned up as a forked branch.\n> >\n> > Oh, so because the default push remote is not \"origin\" we need to\n> > override that for the branches that we do push to \"origin\". That's a\n> > pain, but even if we did add a special case for the default branch, it\n> > would not protect other branches like \"next\" and \"seen\".\n>\n> Thinking about this a bit more, rather than protecting branches where\n> $branch@{push} == $branch@{upstream}, perhaps we should be protecting\n> branches that are merged into their upstream but\n>\n>      git push branch.$branch.remote $branch\n>\n> would update $branch@{upstream}. So we'd apply the push refspec to the\n> branch name, then apply the fetch refspec to that and check the result\n> did not match the name of the upstream branch.\n>\n> Does that make sense?\n\nThis makes a lot of sense and fixes my major gripe. Seems very\npossible to implement as well, I'll give it a shot.\n\nAre you done with the rest of your review so I can push out the next version?\n\n\nHarald\n"},{"id":"548125","messageId":"2fe8c5e0-96d1-46ce-8fda-1b8f521d3c4b@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnUKSD=G1jkdc6n5mVA-NvCgL+c9zCEW_Lr8OBP5q2fKkw@mail.gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-14T13:01:43Z","receivedAt":"2026-07-14T13:01:39Z","isPatch":true,"body":"On 13/07/2026 19:17, Harald Nordgren wrote:\n>>>> This exposes something that I don't love about this feature,\n>>>\n>>> by \"this feature\" do you mean \"git branch --delete-merged\"?\n>>>\n>>>> which is\n>>>> that when using a pushDefault (like we do in the tests with 'git\n>>>> config remote.pushDefault fork') if not adding a special case for the\n>>>> main/master branch (like 'git config branch.main.pushRemote origin'),\n>>>> then it will get cleaned up as a forked branch.\n>>>\n>>> Oh, so because the default push remote is not \"origin\" we need to\n>>> override that for the branches that we do push to \"origin\". That's a\n>>> pain, but even if we did add a special case for the default branch, it\n>>> would not protect other branches like \"next\" and \"seen\".\n>>\n>> Thinking about this a bit more, rather than protecting branches where\n>> $branch@{push} == $branch@{upstream}, perhaps we should be protecting\n>> branches that are merged into their upstream but\n>>\n>>       git push branch.$branch.remote $branch\n>>\n>> would update $branch@{upstream}. So we'd apply the push refspec to the\n>> branch name, then apply the fetch refspec to that and check the result\n>> did not match the name of the upstream branch.\n>>\n>> Does that make sense?\n> \n> This makes a lot of sense and fixes my major gripe. Seems very\n> possible to implement as well, I'll give it a shot.\n\nThat's great\n> Are you done with the rest of your review so I can push out the next version?\n\nYes, though I've just remembered that when we were discussing protecting \nbranches that are the upstreams of another branch Junio was keen for us \nto extend that protection to \"git branch -d\" as well.\n\nThanks\n\nPhillip\n"},{"id":"548136","messageId":"CAHwyqnVoh=V9TL2=-MmoB+RUgx_qL=VdVL_0=p4md83d_9PKyQ@mail.gmail.com","threadId":"65574","inReplyTo":"2fe8c5e0-96d1-46ce-8fda-1b8f521d3c4b@gmail.com","subject":"Re: [PATCH v18 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-14T17:00:25Z","receivedAt":"2026-07-14T17:01:04Z","isPatch":true,"body":"> Yes, though I've just remembered that when we were discussing protecting\n> branches that are the upstreams of another branch Junio was keen for us\n> to extend that protection to \"git branch -d\" as well.\n\nSure, not a bad idea, I'll put it on my list of things to do after\nthis topic has landed.\n\n\nHarald\n"},{"id":"548149","messageId":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v18.git.git.1782338106.gitgitgadget@gmail.com","subject":"[PATCH v19 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:46Z","receivedAt":"2026-07-14T18:24:57Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  50 ++++-\n builtin/branch.c                 | 284 ++++++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 352 +++++++++++++++++++++++++++++++\n 6 files changed, 742 insertions(+), 31 deletions(-)\n\n\nbase-commit: f60db8d575adb79761d363e026fb49bddf330c73\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v19\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v19\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v18:\n\n 1:  3e29ff17bd ! 1:  562648132d branch: add --forked filter for --list mode\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\t(\n      +\t\tcd forked &&\n      +\t\tgit remote add -f other ../forked-other &&\n     -+\t\tgit remote set-head origin one &&\n      +\t\tgit branch local-base &&\n      +\t\tgit branch --track local-one origin/one &&\n      +\t\tgit branch --track local-two origin/two &&\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\t\tgit checkout local-one &&\n      +\t\ttest_commit --no-tag local-one-work local-one.t &&\n      +\t\tgit checkout local-foreign &&\n     -+\t\ttest_commit --no-tag local-foreign-work local-foreign.t &&\n     -+\t\tgit checkout --detach\n     ++\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n      +\t)\n      +'\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked composes with --no-merged' '\n     -+\ttest_when_finished \"git -C forked checkout --detach\" &&\n     -+\tgit -C forked checkout local-one &&\n     -+\ttest_commit -C forked local-only &&\n      +\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n      +\t\t--format=\"%(refname:short)\" >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     -+test_expect_success '--forked rejects unknown branch/pattern' '\n     -+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n     -+\ttest_grep \"not a valid branch or pattern\" err\n     -+'\n     -+\n     -+test_expect_success '--forked requires a value' '\n     -+\ttest_must_fail git -C forked branch --forked 2>err &&\n     -+\ttest_grep \"requires a value\" err\n     -+'\n     -+\n      +test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n      +\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n     -+\techo local-one >expect &&\n     ++\techo main >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +\tEOF\n      +\ttest_cmp expect actual\n      +'\n     ++\n     ++test_expect_success '--forked rejects unknown branch/pattern' '\n     ++\ttest_must_fail git -C forked branch --forked nope 2>err &&\n     ++\ttest_grep \"not a valid branch or pattern\" err\n     ++'\n     ++\n     ++test_expect_success '--forked requires a value' '\n     ++\ttest_must_fail git -C forked branch --forked 2>err &&\n     ++\ttest_grep \"requires a value\" err\n     ++'\n      +\n       test_done\n 2:  cdd4fea4a7 ! 2:  c7ebd9344c branch: convert delete_branches() to a flags argument\n     @@ Metadata\n       ## Commit message ##\n          branch: convert delete_branches() to a flags argument\n      \n     -    delete_branches() and check_branch_commit() take a pair of int\n     -    booleans (force and quiet) that the next commits would grow further.\n     -    Replace them with a single \"unsigned int flags\" argument and an\n     -    enum, splitting the bits back into named bool locals so the body\n     -    keeps reading the same named values.\n     +    delete_branches() takes separate force and quiet parameters, while\n     +    check_branch_commit() takes force. The next commits would grow this\n     +    collection further. Replace them with a single unsigned flags argument\n     +    and an enum.\n     +\n     +    Test the FORCE and QUIET bits directly from flags at each use site so\n     +    that mutating or forwarding flags cannot leave cached values stale.\n      \n          No change in behavior.\n      \n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n      -\t\t\t       int kinds, int force)\n      +\t\t\t       int kinds, unsigned int flags)\n       {\n     -+\tbool force = flags & DELETE_BRANCH_FORCE;\n       \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     - \tif (!force && !rev) {\n     +-\tif (!force && !rev) {\n     ++\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n       \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n     + \t\treturn -1;\n     + \t}\n     +-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     ++\tif (!(flags & DELETE_BRANCH_FORCE) &&\n     ++\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n     + \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n     + \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     + \t\t\t\t  _(\"If you are sure you want to delete it, \"\n      @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       \tstrbuf_release(&buf);\n       }\n     @@ builtin/branch.c: static void delete_branch_config(const char *branchname)\n       {\n       \tstruct commit *head_rev = NULL;\n       \tstruct object_id oid;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     - \tint i;\n     - \tint ret = 0;\n     - \tint remote_branch = 0;\n     -+\tbool force;\n     -+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n     - \tstruct strbuf bname = STRBUF_INIT;\n     - \tenum interpret_branch_kind allowed_interpret;\n     - \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n       \t\tremote_branch = 1;\n       \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \t}\n       \tbranch_name_pos = strcspn(fmt, \"%\");\n       \n     -+\tforce = flags & DELETE_BRANCH_FORCE;\n     -+\n     - \tif (!force)\n     +-\tif (!force)\n     ++\tif (!(flags & DELETE_BRANCH_FORCE))\n       \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n       \n       \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n       \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n       \n       \tnext:\n     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n     + \t\tchar *name = item->string;\n     + \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     + \t\t\tchar *refname = name + branch_name_pos;\n     +-\t\t\tif (!quiet)\n     ++\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n     + \t\t\t\tprintf(remote_branch\n     + \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n     + \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (delete) {\n       \t\tif (!argc)\n 3:  a0fd5b4a6c ! 3:  0c4f3358e3 branch: let delete_branches skip unmerged branches on bulk refusal\n     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n      @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,\n     - \t\t\t       int kinds, unsigned int flags)\n     - {\n     - \tbool force = flags & DELETE_BRANCH_FORCE;\n     -+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     - \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n     - \tif (!force && !rev) {\n     - \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n     - \t\treturn -1;\n       \t}\n     - \tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n     + \tif (!(flags & DELETE_BRANCH_FORCE) &&\n     + \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n      -\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n      -\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n      -\t\t\t\t  _(\"If you are sure you want to delete it, \"\n      -\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n     -+\t\tif (!skip_unmerged) {\n     ++\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n      +\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n      +\t\t\t      branchname);\n      +\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n     @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c\n       \t\treturn -1;\n       \t}\n       \treturn 0;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \tint remote_branch = 0;\n     - \tbool force;\n     - \tbool quiet = flags & DELETE_BRANCH_QUIET;\n     -+\tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     - \tstruct strbuf bname = STRBUF_INIT;\n     - \tenum interpret_branch_kind allowed_interpret;\n     - \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n       \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n       \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n       \t\t\t\t\tflags)) {\n      -\t\t\tret = 1;\n     -+\t\t\tif (!skip_unmerged)\n     ++\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n      +\t\t\t\tret = 1;\n       \t\t\tgoto next;\n       \t\t}\n 4:  a56d8fe93e ! 4:  64a202526a branch: prepare delete_branches for a bulk caller\n     @@ Metadata\n       ## Commit message ##\n          branch: prepare delete_branches for a bulk caller\n      \n     -    Teach delete_branches() two new modes for the upcoming\n     -    --delete-merged: one that asks only whether a branch is merged into\n     -    its upstream, without falling back to HEAD when there is no\n     -    upstream, and one that rehearses the deletions without removing any\n     -    ref. Existing callers keep their current behavior.\n     +    Teach delete_branches() a new mode for the upcoming --delete-merged\n     +    caller that checks whether a branch is merged into its upstream without\n     +    falling back to HEAD when there is no upstream. Existing callers keep\n     +    their current behavior.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ builtin/branch.c: enum delete_branch_flags {\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \tbool force;\n     - \tbool quiet = flags & DELETE_BRANCH_QUIET;\n     - \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     -+\tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n     - \tstruct strbuf bname = STRBUF_INIT;\n     - \tenum interpret_branch_kind allowed_interpret;\n     - \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \n     - \tforce = flags & DELETE_BRANCH_FORCE;\n     + \t}\n     + \tbranch_name_pos = strcspn(fmt, \"%\");\n       \n     --\tif (!force)\n     -+\tif (!force && !no_head_fallback)\n     +-\tif (!(flags & DELETE_BRANCH_FORCE))\n     ++\tif (!(flags & DELETE_BRANCH_FORCE) &&\n     ++\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n       \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n       \n       \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n 5:  a84c555d99 ! 5:  a6caa5b397 branch: add --delete-merged <branch>\n     @@ Metadata\n       ## Commit message ##\n          branch: add --delete-merged <branch>\n      \n     -            git branch --delete-merged <branch>...\n     +        git branch (--delete-merged <branch>)... [<pattern>...]\n      \n     -    deletes the local branches that \"--forked <branch>\" would list,\n     -    keeping only those whose tip is reachable from their configured\n     -    upstream. The work has already landed on the upstream they track,\n     -    so the local copy is no longer needed.\n     +    deletes local branches matching the optional patterns when their\n     +    configured upstream matches one of the --delete-merged arguments and\n     +    their tip is reachable from that upstream. The work has already landed\n     +    on the upstream they track, so the local copy is no longer needed.\n     +\n     +    The option can be repeated to widen the upstream match. Keeping the\n     +    candidate patterns as positional arguments lets users bound the set of\n     +    local branches that may be deleted independently of the upstream\n     +    selection.\n      \n          A branch is not deleted when:\n      \n            * it is checked out in any worktree\n     -      * its upstream remote-tracking branch no longer exists, since a\n     -        missing upstream is not by itself a sign of integration\n     -      * its push destination equals its upstream (<branch>@{push} is\n     -        the same as <branch>@{upstream}), such as a local \"main\" that\n     -        tracks and pushes to \"origin/main\". Right after a pull it just\n     -        looks \"fully merged\", so it is kept. Only branches that push\n     -        somewhere other than their upstream, typically topics in a fork\n     -        workflow, are candidates.\n     +      * its configured upstream ref no longer exists, since a missing\n     +        upstream is not by itself a sign of integration\n     +      * pushing it by name to the remote configured by\n     +        branch.<name>.remote would update its upstream, as determined by\n     +        mapping the branch ref through that remote's fetch refspec. For\n     +        example, a local \"main\" that tracks \"origin/main\" is kept even when\n     +        remote.pushDefault names a fork. Right after a pull it merely looks\n     +        fully merged.\n      \n          A branch whose work is not yet merged into its upstream is silently\n          skipped, so one unmerged topic does not abort the whole sweep.\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     -+git branch --delete-merged <branch>...\n     ++git branch (--delete-merged <branch>)... [<pattern>...]\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n       \tPrint the name of the current branch. In detached `HEAD` state,\n       \tnothing is printed.\n       \n     -+`--delete-merged <branch>...`::\n     -+\tDelete the local branches that `--forked` would list for the\n     -+\tgiven _<branch>_ arguments, but only those whose tip is\n     -+\treachable from their configured upstream. In other words, the\n     -+\twork on the branch has already landed on the upstream it\n     -+\ttracks, so the local copy is no longer needed. Several\n     -+\t_<branch>_ patterns may be given, e.g. `git branch\n     -+\t--delete-merged origin/main 'feature*'`.\n     ++`--delete-merged <branch>`::\n     ++\tDelete local branches whose configured upstream matches\n     ++\t_<branch>_, but only when their tip is reachable from that\n     ++\tupstream. In other words, the work on the branch has already\n     ++\tlanded on the upstream it tracks, so the local copy is no longer\n     ++\tneeded. The option can be repeated to widen the upstream match.\n     ++\tOptional _<pattern>_ arguments limit which local branches are\n     ++\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n     ++\t'topic-*'`.\n      ++\n      +A branch is not deleted when:\n      ++\n      +--\n     -+* its upstream remote-tracking branch no longer exists,\n     ++* its configured upstream ref no longer exists,\n      +* it is checked out in any worktree, or\n     -+* its push destination (`<branch>@{push}`) equals its upstream\n     -+  (`<branch>@{upstream}`), so it cannot be distinguished from a\n     -+  branch that just looks \"fully merged\" right after a pull.\n     ++* pushing it by name to the remote configured by\n     ++  `branch.<name>.remote` would update its upstream, so it cannot be\n     ++  distinguished from a branch that just looks \"fully merged\" right\n     ++  after a pull.\n      +--\n      ++\n      +A branch whose work has not yet been merged into its upstream is\n     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] --delete-merged <branch>...\"),\n     ++\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n       \tNULL\n       };\n       \n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstrset_clear(&spared);\n      +}\n      +\n     -+static int delete_merged_branches(int argc, const char **argv,\n     -+\t\t\t\t unsigned int flags)\n     ++static int branch_pushes_to_upstream(struct branch *branch,\n     ++\t\t\t\t     const char *upstream)\n     ++{\n     ++\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n     ++\tchar *tracking = NULL;\n     ++\tint ret = 0;\n     ++\n     ++\tif (remote)\n     ++\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n     ++\tif (tracking && !strcmp(tracking, upstream))\n     ++\t\tret = 1;\n     ++\n     ++\tfree(tracking);\n     ++\treturn ret;\n     ++}\n     ++\n     ++static int delete_merged_branches(const struct strvec *upstreams,\n     ++\t\t\t\t const char **argv, unsigned int flags)\n      +{\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct strvec to_delete = STRVEC_INIT;\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n     -+\tint i, ret = 0;\n     ++\tsize_t i;\n     ++\tint ret = 0;\n      +\n     -+\tif (!argc)\n     -+\t\tdie(_(\"--delete-merged requires at least one <branch>\"));\n     -+\n     -+\tfor (i = 0; i < argc; i++)\n     -+\t\tif (ref_filter_forked_add(&filter, argv[i]) < 0)\n     -+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"), argv[i]);\n     ++\tfor (i = 0; i < upstreams->nr; i++)\n     ++\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n     ++\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n     ++\t\t\t    upstreams->v[i]);\n      +\n      +\tfilter.kind = FILTER_REFS_BRANCHES;\n     ++\tfilter.name_patterns = argv;\n      +\tfilter_refs(&candidates, &filter, filter.kind);\n      +\n     -+\tfor (i = 0; i < candidates.nr; i++) {\n     ++\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n      +\t\tconst char *full_name = candidates.items[i]->refname;\n      +\t\tconst char *short_name;\n      +\t\tstruct branch *branch;\n     -+\t\tconst char *upstream, *push;\n     ++\t\tconst char *upstream;\n      +\n      +\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n      +\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\tupstream = branch_get_upstream(branch, NULL);\n      +\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n      +\t\t\tcontinue;\n     -+\t\tpush = branch_get_push(branch, NULL);\n     -+\t\tif (!push || !strcmp(push, upstream))\n     ++\t\tif (branch_pushes_to_upstream(branch, upstream))\n      +\t\t\tcontinue;\n      +\t\tif (check_branch_commit(short_name, short_name,\n      +\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t/* possible actions */\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     -+\tint delete_merged = 0;\n     ++\tstruct strvec delete_merged = STRVEC_INIT;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n       \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n     -+\t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n     -+\t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n     ++\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n     ++\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n     ++\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n       \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \n       \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n      -\t    !show_current && !unset_upstream && argc == 0)\n     -+\t    !show_current && !unset_upstream && !delete_merged &&\n     ++\t    !show_current && !unset_upstream && !delete_merged.nr &&\n      +\t    argc == 0)\n       \t\tlist = 1;\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n       \t\t\t    !!show_current + !!list + !!edit_description +\n      -\t\t\t    !!unset_upstream;\n     -+\t\t\t    !!unset_upstream + !!delete_merged;\n     ++\t\t\t    !!unset_upstream + !!delete_merged.nr;\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n       \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n       \t\tgoto out;\n     -+\t} else if (delete_merged) {\n     -+\t\tret = delete_merged_branches(argc, argv,\n     ++\t} else if (delete_merged.nr) {\n     ++\t\tret = delete_merged_branches(&delete_merged, argv,\n      +\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n      +\t\tgoto out;\n       \t} else if (show_current) {\n       \t\tprint_current_branch_name();\n       \t\tret = 0;\n     +@@ builtin/branch.c: int cmd_branch(int argc,\n     + \tret = 0;\n     + \n     + out:\n     ++\tstrvec_clear(&delete_merged);\n     + \tstring_list_clear(&sorting_options, 0);\n     + \treturn ret;\n     + }\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n     - \ttest_cmp expect actual\n     +@@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n     + \ttest_grep \"requires a value\" err\n       '\n       \n      +test_expect_success '--delete-merged: setup' '\n     @@ t/t3200-branch.sh: test_expect_success '--forked narrows a <pattern> argument' '\n      +\t\tcd repo &&\n      +\t\tgit remote add fork ../fork &&\n      +\t\tgit remote add other ../other &&\n     -+\t\tgit config remote.pushDefault fork &&\n      +\t\tgit config push.default current &&\n      +\t\tgit fetch other\n      +\t)\n      +}\n      +\n     -+merged_branch () {\n     ++create_merged_branch () {\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b \"$1\" \"$2\" &&\n     ++\t\tgit checkout -b \"$1\" origin/next --track &&\n      +\t\tgit commit --allow-empty -m \"$1 work\" &&\n     -+\t\tgit push origin \"$1:next\" &&\n     -+\t\tgit fetch origin &&\n     -+\t\tgit branch --set-upstream-to=\"$2\" \"$1\"\n     ++\t\tgit push origin \"$1:next\"\n      +\t)\n      +}\n      +\n     -+test_expect_success '--delete-merged deletes merged branches and spares the rest' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n     -+\tsetup_repo_for_delete_merged &&\n     -+\tmerged_branch merged origin/next &&\n     -+\t(\n     -+\t\tcd repo &&\n     -+\t\tgit checkout -b unmerged origin/next &&\n     -+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n     -+\t\tgit branch --set-upstream-to=origin/next unmerged &&\n     -+\t\tgit checkout -b tracks-other other/main &&\n     -+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n     -+\t\tgit checkout --detach\n     -+\t) &&\n     -+\tsha=$(git -C repo rev-parse --short merged) &&\n     -+\n     -+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     -+\n     -+\techo \"Deleted branch merged (was $sha).\" >expect &&\n     -+\ttest_cmp expect actual &&\n     -+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     -+\tmain\n     -+\ttracks-other\n     -+\tunmerged\n     -+\tEOF\n     ++check_branches () {\n     ++\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     ++\tcat >expect &&\n      +\ttest_cmp expect actual\n     -+'\n     ++}\n      +\n     -+test_expect_success '--delete-merged deletes merged branches and spares protected ones' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n     ++test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n      +\tsetup_repo_for_delete_merged &&\n     -+\tmerged_branch on-next origin/next &&\n     -+\tmerged_branch checked-out origin/next &&\n     -+\tmerged_branch upstream-gone origin/next &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b mainline main &&\n     -+\t\tgit checkout -b on-local mainline &&\n     -+\t\tgit branch --set-upstream-to=mainline on-local &&\n     -+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n     -+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n     -+\t\tgit update-ref -d refs/remotes/origin/topic &&\n     -+\t\tgit branch --set-upstream-to=origin/main main &&\n     -+\t\tgit config branch.main.pushRemote origin &&\n     -+\t\tgit checkout -b tracks-other other/main &&\n     -+\t\tgit branch --set-upstream-to=other/main tracks-other &&\n     -+\t\tgit checkout checked-out\n     -+\t) &&\n     ++\t\tgit checkout --detach &&\n      +\n     -+\tgit -C repo branch --delete-merged origin/next mainline &&\n     ++\t\tgit branch --delete-merged */* &&\n      +\n     -+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     -+\tchecked-out\n     -+\tmain\n     -+\tmainline\n     -+\ttracks-other\n     -+\tupstream-gone\n     -+\tEOF\n     -+\ttest_cmp expect actual\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\tmain\n     ++\t\tEOF\n     ++\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged requires at least one <branch>' '\n     -+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n     -+\ttest_grep \"requires at least one <branch>\" err\n     ++test_expect_success '--delete-merged deletes only selected merged branches' '\n     ++\tsetup_repo_for_delete_merged &&\n     ++\tcreate_merged_branch also-merged &&\n     ++\tcreate_merged_branch merged &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b unmerged origin/next --track &&\n     ++\t\tgit commit --allow-empty -m \"unmerged work\" &&\n     ++\t\tgit checkout -b tracks-other other/main --track &&\n     ++\t\tsha=$(git rev-parse --short merged) &&\n     ++\n     ++\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n     ++\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n     ++\t\ttest_cmp expect actual &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\talso-merged\n     ++\t\tmain\n     ++\t\ttracks-other\n     ++\t\tunmerged\n     ++\t\tEOF\n     ++\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged keeps a branch that is an upstream' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n     ++test_expect_success '--delete-merged keeps main despite a different default push remote' '\n      +\tsetup_repo_for_delete_merged &&\n     -+\tmerged_branch feature origin/next &&\n     ++\tcreate_merged_branch on-next &&\n     ++\tcreate_merged_branch checked-out &&\n     ++\tcreate_merged_branch upstream-gone &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b topic feature &&\n     -+\t\tgit commit --allow-empty -m \"topic work\" &&\n     -+\t\tgit branch --set-upstream-to=feature topic &&\n     -+\t\tgit checkout --detach\n     -+\t) &&\n     -+\n     -+\tgit -C repo branch --dry-run --delete-merged origin/next >out &&\n     -+\ttest_grep ! \"feature\" out &&\n     -+\n     -+\tgit -C repo branch --delete-merged origin/next 2>err &&\n     -+\n     -+\ttest_must_be_empty err &&\n     -+\tgit -C repo rev-parse --verify refs/heads/feature &&\n     -+\tgit -C repo rev-parse --verify refs/heads/topic &&\n     -+\techo origin/next >expect &&\n     -+\tgit -C repo rev-parse --abbrev-ref feature@{upstream} >actual &&\n     -+\ttest_cmp expect actual &&\n     -+\techo feature >expect &&\n     -+\tgit -C repo rev-parse --abbrev-ref topic@{upstream} >actual &&\n     -+\ttest_cmp expect actual\n     ++\t\tgit config remote.pushDefault fork &&\n     ++\t\tgit checkout -b local-to-delete main --track &&\n     ++\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n     ++\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n     ++\t\tgit update-ref -d refs/remotes/origin/topic &&\n     ++\t\tgit checkout -b tracks-other other/main --track &&\n     ++\t\tgit checkout checked-out &&\n     ++\n     ++\t\tgit branch --delete-merged origin/* \\\n     ++\t\t\t--delete-merged main &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\tchecked-out\n     ++\t\tmain\n     ++\t\ttracks-other\n     ++\t\tupstream-gone\n     ++\t\tEOF\n     ++\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged keeps a chain of upstreams of a kept branch' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n     ++test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n      +\tsetup_repo_for_delete_merged &&\n     ++\tcreate_merged_branch feature &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit branch b3 origin/next &&\n     -+\t\tgit branch --set-upstream-to=origin/next b3 &&\n     -+\t\tgit branch b2 origin/next &&\n     -+\t\tgit branch --set-upstream-to=b3 b2 &&\n     -+\t\tgit checkout -b b1 b2 &&\n     -+\t\tgit commit --allow-empty -m \"b1 work\" &&\n     -+\t\tgit branch --set-upstream-to=b2 b1 &&\n     -+\t\tgit checkout --detach\n     -+\t) &&\n     -+\n     -+\tgit -C repo branch --delete-merged origin/next &&\n     ++\t\tgit checkout -b topic feature --track &&\n     ++\t\tgit commit --allow-empty -m \"topic work\" &&\n      +\n     -+\tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     -+\tcat >expect <<-\\EOF &&\n     -+\tb1\n     -+\tb2\n     -+\tb3\n     -+\tmain\n     -+\tEOF\n     -+\ttest_cmp expect actual\n     ++\t\tgit branch --delete-merged origin/next 2>err &&\n     ++\n     ++\t\ttest_must_be_empty err &&\n     ++\t\tcheck_branches <<-\\EOF &&\n     ++\t\tfeature\n     ++\t\tmain\n     ++\t\ttopic\n     ++\t\tEOF\n     ++\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n     ++\t\tcat >expect <<-\\EOF &&\n     ++\t\tbranch.feature.remote origin\n     ++\t\tbranch.feature.merge refs/heads/next\n     ++\t\tbranch.topic.remote .\n     ++\t\tbranch.topic.merge refs/heads/feature\n     ++\t\tEOF\n     ++\t\ttest_cmp expect actual\n     ++\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged clears the upstream of a kept base whose own base is deleted' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n     ++test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '\n      +\tsetup_repo_for_delete_merged &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit branch lower origin/next &&\n     -+\t\tgit branch --set-upstream-to=origin/next lower &&\n     -+\t\tgit branch mid origin/next &&\n     -+\t\tgit branch --set-upstream-to=lower mid &&\n     -+\t\tgit checkout -b tip mid &&\n     ++\t\tgit config remote.pushDefault fork &&\n     ++\t\tgit branch lower origin/next --track &&\n     ++\t\tgit branch mid lower --track &&\n     ++\t\tgit checkout -b tip mid --track &&\n      +\t\tgit commit --allow-empty -m \"tip work\" &&\n     -+\t\tgit branch --set-upstream-to=mid tip &&\n     -+\t\tgit checkout --detach\n     -+\t) &&\n     -+\n     -+\tgit -C repo branch --delete-merged origin/next lower &&\n      +\n     -+\ttest_must_fail git -C repo rev-parse --verify refs/heads/lower &&\n     -+\tgit -C repo rev-parse --verify refs/heads/mid &&\n     -+\ttest_must_fail git -C repo rev-parse mid@{upstream} &&\n     -+\techo mid >expect &&\n     -+\tgit -C repo rev-parse --abbrev-ref tip@{upstream} >actual &&\n     -+\ttest_cmp expect actual\n     ++\t\tgit branch --delete-merged origin/next \\\n     ++\t\t\t--delete-merged lower &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF &&\n     ++\t\tmain\n     ++\t\tmid\n     ++\t\ttip\n     ++\t\tEOF\n     ++\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tcat >expect <<-\\EOF &&\n     ++\t\tbranch.tip.remote .\n     ++\t\tbranch.tip.merge refs/heads/mid\n     ++\t\tEOF\n     ++\t\ttest_cmp expect actual\n     ++\t)\n      +'\n      +\n     ++test_expect_success '--delete-merged requires a value' '\n     ++\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n     ++\ttest_grep \"requires a value\" err\n     ++'\n       test_done\n 6:  d52d717b70 ! 6:  734d27c908 branch: add branch.<name>.deleteMerged opt-out\n     @@ Documentation/git-branch.adoc\n      @@ Documentation/git-branch.adoc: A branch is not deleted when:\n       +\n       --\n     - * its upstream remote-tracking branch no longer exists,\n     + * its configured upstream ref no longer exists,\n      -* it is checked out in any worktree, or\n      +* it is checked out in any worktree,\n     - * its push destination (`<branch>@{push}`) equals its upstream\n     -   (`<branch>@{upstream}`), so it cannot be distinguished from a\n     --  branch that just looks \"fully merged\" right after a pull.\n     -+  branch that just looks \"fully merged\" right after a pull, or\n     + * pushing it by name to the remote configured by\n     +   `branch.<name>.remote` would update its upstream, so it cannot be\n     +   distinguished from a branch that just looks \"fully merged\" right\n     +-  after a pull.\n     ++  after a pull, or\n      +* `branch.<name>.deleteMerged` is set to `false`.\n       --\n       +\n       A branch whose work has not yet been merged into its upstream is\n      \n       ## builtin/branch.c ##\n     -@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \tstruct ref_array candidates = { 0 };\n       \tstruct strset deletable = STRSET_INIT;\n       \tstruct strvec to_delete = STRVEC_INIT;\n      +\tstruct strbuf key = STRBUF_INIT;\n       \tstruct hashmap_iter iter;\n       \tstruct strmap_entry *entry;\n     -+\tbool quiet = flags & DELETE_BRANCH_QUIET;\n     - \tint i, ret = 0;\n     - \n     - \tif (!argc)\n     -@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     + \tsize_t i;\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \t\tconst char *short_name;\n       \t\tstruct branch *branch;\n     - \t\tconst char *upstream, *push;\n     + \t\tconst char *upstream;\n      +\t\tint opt_out;\n       \n       \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n       \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n     -@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n       \t\t\tcontinue;\n       \n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n      +\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n      +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n      +\t\t    !opt_out) {\n     -+\t\t\tif (!quiet)\n     ++\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n      +\t\t\t\tfprintf(stderr,\n      +\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n      +\t\t\t\t\tshort_name, short_name);\n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \t\tstrset_add(&deletable, short_name);\n       \t}\n       \n     -@@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n       \t\t\t\t      flags);\n       \n     @@ builtin/branch.c: static int delete_merged_branches(int argc, const char **argv,\n       \tref_array_clear(&candidates);\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the upstream of a kept base whose ow\n     - \ttest_cmp expect actual\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '\n     + \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n     + \ttest_grep \"requires a value\" err\n       '\n     - \n     ++\n      +test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n      +\tsetup_repo_for_delete_merged &&\n     -+\tmerged_branch deleted origin/next &&\n     -+\tmerged_branch kept origin/next &&\n     -+\tgit -C repo config branch.kept.deleteMerged false &&\n     -+\tgit -C repo checkout --detach &&\n     ++\tcreate_merged_branch deleted &&\n     ++\tcreate_merged_branch kept &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit config branch.kept.deleteMerged false &&\n     ++\t\tgit checkout --detach &&\n      +\n     -+\tgit -C repo branch --delete-merged origin/next 2>err &&\n     ++\t\tgit branch --delete-merged origin/next 2>err &&\n      +\n     -+\ttest_grep \"Skipping .kept.\" err &&\n     -+\ttest_must_fail git -C repo rev-parse --verify refs/heads/deleted &&\n     -+\tgit -C repo rev-parse --verify refs/heads/kept\n     ++\t\ttest_grep \"Skipping .kept.\" err &&\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\tkept\n     ++\t\tmain\n     ++\t\tEOF\n     ++\t)\n      +'\n      +\n      +test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n     -+\ttest_when_finished \"rm -rf repo\" &&\n      +\tsetup_repo_for_delete_merged &&\n     -+\tmerged_branch kept origin/next &&\n     -+\tgit -C repo config branch.kept.deleteMerged false &&\n     -+\tgit -C repo checkout --detach &&\n     ++\tcreate_merged_branch kept &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit config branch.kept.deleteMerged false &&\n     ++\t\tgit checkout --detach &&\n     ++\n     ++\t\tgit branch -d kept &&\n      +\n     -+\tgit -C repo branch -d kept &&\n     -+\ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\tmain\n     ++\t\tEOF\n     ++\t)\n      +'\n      +\n       test_done\n 7:  8d0323f4b3 ! 7:  7aa9d5db14 branch: add --dry-run for --delete-merged\n     @@ Metadata\n       ## Commit message ##\n          branch: add --dry-run for --delete-merged\n      \n     -    With --dry-run, --delete-merged prints the local branches it would\n     -    delete, one \"Would delete branch <name>\" line each, and exits\n     -    without touching any ref. The same filtering applies, so the output\n     -    is exactly the set that the real run would delete.\n     +    \"git branch --dry-run --delete-merged ...\" prints one line per ref that\n     +    would be deleted without modifying refs or branch configuration.\n      \n          --dry-run is only meaningful together with --delete-merged and is\n          rejected otherwise.\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     --git branch --delete-merged <branch>...\n     -+git branch [--dry-run] --delete-merged <branch>...\n     +-git branch (--delete-merged <branch>)... [<pattern>...]\n     ++git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n       \n       DESCRIPTION\n       -----------\n     @@ builtin/branch.c: enum delete_branch_flags {\n       };\n       \n       static int check_branch_commit(const char *branchname, const char *refname,\n     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n     - \tbool quiet = flags & DELETE_BRANCH_QUIET;\n     - \tbool skip_unmerged = flags & DELETE_BRANCH_SKIP_UNMERGED;\n     - \tbool no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;\n     -+\tbool dry_run = flags & DELETE_BRANCH_DRY_RUN;\n     - \tstruct strbuf bname = STRBUF_INIT;\n     - \tenum interpret_branch_kind allowed_interpret;\n     - \tstruct string_list refs_to_delete = STRING_LIST_INIT_DUP;\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,\n       \t\tfree(target);\n       \t}\n       \n      -\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n     -+\tif (!dry_run &&\n     ++\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n      +\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n       \t\tret = 1;\n       \n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n       \t\tchar *describe_ref = item->util;\n       \t\tchar *name = item->string;\n      -\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n     -+\t\tif (dry_run) {\n     -+\t\t\tif (!quiet)\n     ++\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n     ++\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n      +\t\t\t\tprintf(remote_branch\n      +\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n      +\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n      +\t\t\t\t\tname + branch_name_pos, describe_ref);\n      +\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n       \t\t\tchar *refname = name + branch_name_pos;\n     - \t\t\tif (!quiet)\n     + \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n       \t\t\t\tprintf(remote_branch\n     +@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)\n     +  * base is itself merged, so when its own upstream is also going away\n     +  * (no surviving branch tracks it), clear the base's now-stale upstream.\n     +  */\n     +-static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n     ++static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,\n     ++\t\t\t\tunsigned int flags)\n     + {\n     + \tstruct strset spared = STRSET_INIT;\n     + \tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n     +@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable\n     + \n     + \trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n     + \n     +-\tstrset_for_each_entry(&spared, &iter, entry) {\n     +-\t\tstruct branch *branch = branch_get(entry->key);\n     +-\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     +-\t\tconst char *up_short;\n     ++\tif (!(flags & DELETE_BRANCH_DRY_RUN)) {\n     ++\t\tstrset_for_each_entry(&spared, &iter, entry) {\n     ++\t\t\tstruct branch *branch = branch_get(entry->key);\n     ++\t\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     ++\t\t\tconst char *up_short;\n     + \n     +-\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     +-\t\t    !strset_contains(deletable, up_short))\n     +-\t\t\tcontinue;\n     ++\t\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     ++\t\t\t    !strset_contains(deletable, up_short))\n     ++\t\t\t\tcontinue;\n     + \n     +-\t\tstrbuf_reset(&key);\n     +-\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     +-\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     +-\t\tstrbuf_reset(&key);\n     +-\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     +-\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\t\tstrbuf_reset(&key);\n     ++\t\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     ++\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\t\tstrbuf_reset(&key);\n     ++\t\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     ++\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\t}\n     + \t}\n     + \n     + \tstrbuf_release(&key);\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     + \t\tstrset_add(&deletable, short_name);\n     + \t}\n     + \n     +-\tspare_stacked_bases(refs, &deletable);\n     ++\tspare_stacked_bases(refs, &deletable, flags);\n     + \n     + \tstrset_for_each_entry(&deletable, &iter, entry)\n     + \t\tstrvec_push(&to_delete, entry->key);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     - \tint delete_merged = 0;\n     + \tstruct strvec delete_merged = STRVEC_INIT;\n      +\tint dry_run = 0;\n       \tconst char *new_upstream = NULL;\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\t\t N_(\"edit the description for the branch\")),\n     - \t\tOPT_BOOL(0, \"delete-merged\", &delete_merged,\n     - \t\t\tN_(\"delete local branches whose upstream matches <branch> and are merged\")),\n     + \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n     + \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n     + \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n      +\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tif (noncreate_actions > 1)\n       \t\tusage_with_options(builtin_branch_usage, options);\n       \n     -+\tif (dry_run && !delete_merged)\n     ++\tif (dry_run && !delete_merged.nr)\n      +\t\tdie(_(\"--dry-run requires --delete-merged\"));\n      +\n       \tif (recurse_submodules_explicit) {\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tgoto out;\n     - \t} else if (delete_merged) {\n     - \t\tret = delete_merged_branches(argc, argv,\n     + \t} else if (delete_merged.nr) {\n     + \t\tret = delete_merged_branches(&delete_merged, argv,\n      -\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n      +\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n      +\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tprint_current_branch_name();\n      \n       ## t/t3200-branch.sh ##\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes merged branches and spares the rest\n     - \t) &&\n     - \tsha=$(git -C repo rev-parse --short merged) &&\n     - \n     --\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     -+\tgit -C repo branch --dry-run --delete-merged origin/next >actual 2>&1 &&\n     -+\techo \"Would delete branch merged (was $sha).\" >expect &&\n     -+\ttest_cmp expect actual &&\n     -+\tgit -C repo rev-parse --verify refs/heads/merged &&\n     - \n     -+\tgit -C repo branch --delete-merged origin/next >actual 2>&1 &&\n     - \techo \"Deleted branch merged (was $sha).\" >expect &&\n     - \ttest_cmp expect actual &&\n     - \tgit -C repo for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '\n     + \t\tgit checkout -b tracks-other other/main --track &&\n     + \t\tsha=$(git rev-parse --short merged) &&\n     + \n     ++\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n     ++\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n     ++\t\ttest_cmp expect actual &&\n     ++\t\tgit rev-parse --verify refs/heads/merged &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF &&\n     ++\t\talso-merged\n     ++\t\tmain\n     ++\t\tmerged\n     ++\t\ttracks-other\n     ++\t\tunmerged\n     ++\t\tEOF\n     ++\n     + \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n     + \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n     + \t\ttest_cmp expect actual &&\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n     + \t\tgit checkout -b topic feature --track &&\n     + \t\tgit commit --allow-empty -m \"topic work\" &&\n     + \n     +-\t\tgit branch --delete-merged origin/next 2>err &&\n     ++\t\tgit branch --dry-run --delete-merged origin/next >out &&\n     ++\t\ttest_grep ! \"feature\" out &&\n     + \n     ++\t\tgit branch --delete-merged origin/next 2>err &&\n     + \t\ttest_must_be_empty err &&\n     ++\n     + \t\tcheck_branches <<-\\EOF &&\n     + \t\tfeature\n     + \t\tmain\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra\n     + \t\tgit checkout -b tip mid --track &&\n     + \t\tgit commit --allow-empty -m \"tip work\" &&\n     + \n     ++\t\tgit branch --dry-run --delete-merged origin/next \\\n     ++\t\t\t--delete-merged lower &&\n     ++\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tcat >expect <<-\\EOF &&\n     ++\t\tbranch.mid.remote .\n     ++\t\tbranch.mid.merge refs/heads/lower\n     ++\t\tbranch.tip.remote .\n     ++\t\tbranch.tip.merge refs/heads/mid\n     ++\t\tEOF\n     ++\t\ttest_cmp expect actual &&\n     ++\n     + \t\tgit branch --delete-merged origin/next \\\n     + \t\t\t--delete-merged lower &&\n     + \n      @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n     - \ttest_must_fail git -C repo rev-parse --verify refs/heads/kept\n     + \t)\n       '\n       \n      +test_expect_success '--dry-run without --delete-merged is rejected' '\n\n-- \ngitgitgadget\n"},{"id":"548146","messageId":"562648132d19eb729fb80395fb4f1528d98b8b32.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:47Z","receivedAt":"2026-07-14T18:24:58Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 ++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 224 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1572a4f9ef..c159f45b4c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -770,6 +780,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -815,7 +828,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 284796c49b..cbdac1a19a 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex e7829c2c4b..0c5a4ca62b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1717,4 +1717,121 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548147","messageId":"c7ebd9344c620772e5bf5260cc16b356791b7ddc.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:48Z","receivedAt":"2026-07-14T18:25:00Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c159f45b4c..e905a13a95 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -872,7 +878,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"548148","messageId":"0c4f3358e30c24fc909613c5f1aecf7b87b81236.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:49Z","receivedAt":"2026-07-14T18:25:01Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex e905a13a95..568ae817d6 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"548150","messageId":"64a202526a673314ffb9d4cc4036b08434bef21e.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:50Z","receivedAt":"2026-07-14T18:25:03Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 568ae817d6..23b2b7107c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"548151","messageId":"a6caa5b397da8ea24eb97e6aa6dc92b437e456ef.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:51Z","receivedAt":"2026-07-14T18:25:05Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <branch>)... [<pattern>...]\n\ndeletes local branches matching the optional patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nThe option can be repeated to widen the upstream match. Keeping the\ncandidate patterns as positional arguments lets users bound the set of\nlocal branches that may be deleted independently of the upstream\nselection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it by name to the remote configured by\n    branch.<name>.remote would update its upstream, as determined by\n    mapping the branch ref through that remote's fetch refspec. For\n    example, a local \"main\" that tracks \"origin/main\" is kept even when\n    remote.pushDefault names a fork. Right after a pull it merely looks\n    fully merged.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that another, surviving branch tracks as its upstream is\nalso kept, so a branch is never deleted out from under one stacked\non top of it. Such a kept branch is itself merged, so when its own\nupstream is being deleted, clear its now-stale upstream config.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  31 +++++++\n builtin/branch.c              | 164 ++++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             | 166 ++++++++++++++++++++++++++++++++++\n 3 files changed, 359 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..cee3904cfd 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,36 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<branch>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. The option can be repeated to widen the upstream match.\n+\tOptional _<pattern>_ arguments limit which local branches are\n+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree, or\n+* pushing it by name to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks \"fully merged\" right\n+  after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that another, surviving branch tracks as its upstream is\n+kept, so a branch is never deleted out from under one stacked on top\n+of it. If that kept branch in turn tracks a branch that is being\n+deleted, its now-stale upstream configuration is cleared.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 23b2b7107c..8ce8840fa7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n \tNULL\n };\n \n@@ -699,6 +701,154 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct spare_data {\n+\tstruct strset *deletable;\n+\tstruct strset *spared;\n+};\n+\n+/*\n+ * A surviving branch stacked on a deletion candidate would lose its\n+ * upstream, so drop that candidate from the delete set and remember it\n+ * in \"spared\" so its own upstream can be tidied up afterwards.\n+ */\n+static int spare_stacked_base(const struct reference *ref, void *cb_data)\n+{\n+\tstruct spare_data *data = cb_data;\n+\tstruct branch *branch;\n+\tconst char *upstream, *up_short;\n+\n+\tif (strset_contains(data->deletable, ref->name))\n+\t\treturn 0;\n+\tbranch = branch_get(ref->name);\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n+\t    !strset_contains(data->deletable, up_short))\n+\t\treturn 0;\n+\n+\tstrset_remove(data->deletable, up_short);\n+\tstrset_add(data->spared, up_short);\n+\treturn 0;\n+}\n+\n+/*\n+ * Keep any branch that a surviving branch tracks as its upstream, so we\n+ * never delete a branch out from under one stacked on top of it.  Such a\n+ * base is itself merged, so when its own upstream is also going away\n+ * (no surviving branch tracks it), clear the base's now-stale upstream.\n+ */\n+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n+{\n+\tstruct strset spared = STRSET_INIT;\n+\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n+\tstruct strbuf key = STRBUF_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n+\n+\tstrset_for_each_entry(&spared, &iter, entry) {\n+\t\tstruct branch *branch = branch_get(entry->key);\n+\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n+\t\tconst char *up_short;\n+\n+\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n+\t\t    !strset_contains(deletable, up_short))\n+\t\t\tcontinue;\n+\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t}\n+\n+\tstrbuf_release(&key);\n+\tstrset_clear(&spared);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (remote)\n+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable = STRSET_INIT;\n+\tstruct strvec to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tsize_t i;\n+\tint ret = 0;\n+\n+\tfor (i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n+\t\tconst char *full_name = candidates.items[i]->refname;\n+\t\tconst char *short_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream;\n+\n+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n+\t\tif (branch_checked_out(full_name))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(short_name);\n+\t\tupstream = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(short_name, short_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable, short_name);\n+\t}\n+\n+\tspare_stacked_bases(refs, &deletable);\n+\n+\tstrset_for_each_entry(&deletable, &iter, entry)\n+\t\tstrvec_push(&to_delete, entry->key);\n+\n+\tif (to_delete.nr)\n+\t\tret = delete_branches(to_delete.nr, to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&to_delete);\n+\tstrset_clear(&deletable);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -740,6 +890,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -793,6 +944,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -840,7 +994,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -850,7 +1005,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -892,6 +1047,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1051,6 +1210,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 0c5a4ca62b..fa8a60c9e7 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1834,4 +1834,170 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" origin/next --track &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next --track &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete main --track &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* \\\n+\t\t\t--delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature --track &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit branch lower origin/next --track &&\n+\t\tgit branch mid lower --track &&\n+\t\tgit checkout -b tip mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548152","messageId":"734d27c908d24fe23e3475c820333415af5caf1e.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:52Z","receivedAt":"2026-07-14T18:25:06Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 60 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex cee3904cfd..ffb39811ab 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,11 +216,12 @@ A branch is not deleted when:\n +\n --\n * its configured upstream ref no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * pushing it by name to the remote configured by\n   `branch.<name>.remote` would update its upstream, so it cannot be\n   distinguished from a branch that just looks \"fully merged\" right\n-  after a pull.\n+  after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 8ce8840fa7..61f414b3c7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -791,6 +791,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable = STRSET_INIT;\n \tstruct strvec to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tsize_t i;\n@@ -810,6 +811,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *short_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n@@ -827,6 +829,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tshort_name, short_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable, short_name);\n \t}\n \n@@ -842,6 +855,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&to_delete);\n \tstrset_clear(&deletable);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex fa8a60c9e7..54292bfbdf 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2000,4 +2000,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548153","messageId":"7aa9d5db144f6e9d9246f1609c67336398e629d7.1784053493.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v19 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-14T18:24:53Z","receivedAt":"2026-07-14T18:25:09Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++-\n builtin/branch.c              | 54 +++++++++++++++++++++++------------\n t/t3200-branch.sh             | 35 ++++++++++++++++++++++-\n 3 files changed, 77 insertions(+), 20 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex ffb39811ab..633031f248 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <branch>)... [<pattern>...]\n+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -233,6 +233,12 @@ kept, so a branch is never deleted out from under one stacked on top\n of it. If that kept branch in turn tracks a branch that is being\n deleted, its now-stale upstream configuration is cleared.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 61f414b3c7..117af854a0 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -340,13 +341,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -736,7 +744,8 @@ static int spare_stacked_base(const struct reference *ref, void *cb_data)\n  * base is itself merged, so when its own upstream is also going away\n  * (no surviving branch tracks it), clear the base's now-stale upstream.\n  */\n-static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,\n+\t\t\t\tunsigned int flags)\n {\n \tstruct strset spared = STRSET_INIT;\n \tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n@@ -746,21 +755,23 @@ static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable\n \n \trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n \n-\tstrset_for_each_entry(&spared, &iter, entry) {\n-\t\tstruct branch *branch = branch_get(entry->key);\n-\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n-\t\tconst char *up_short;\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN)) {\n+\t\tstrset_for_each_entry(&spared, &iter, entry) {\n+\t\t\tstruct branch *branch = branch_get(entry->key);\n+\t\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n+\t\t\tconst char *up_short;\n \n-\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n-\t\t    !strset_contains(deletable, up_short))\n-\t\t\tcontinue;\n+\t\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n+\t\t\t    !strset_contains(deletable, up_short))\n+\t\t\t\tcontinue;\n \n-\t\tstrbuf_reset(&key);\n-\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n-\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n-\t\tstrbuf_reset(&key);\n-\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n-\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\t\tstrbuf_reset(&key);\n+\t\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\t\tstrbuf_reset(&key);\n+\t\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\t}\n \t}\n \n \tstrbuf_release(&key);\n@@ -843,7 +854,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tstrset_add(&deletable, short_name);\n \t}\n \n-\tspare_stacked_bases(refs, &deletable);\n+\tspare_stacked_bases(refs, &deletable, flags);\n \n \tstrset_for_each_entry(&deletable, &iter, entry)\n \t\tstrvec_push(&to_delete, entry->key);\n@@ -905,6 +916,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -961,6 +973,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1023,6 +1037,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1063,7 +1080,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 54292bfbdf..c055bc8287 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1900,6 +1900,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other other/main --track &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -1948,9 +1961,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic feature --track &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -1978,6 +1994,18 @@ test_expect_success '--delete-merged clears the deleted upstream of a spared bra\n \t\tgit checkout -b tip mid --track &&\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower &&\n \n@@ -2036,4 +2064,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"548618","messageId":"xmqqtspvptqc.fsf@gitster.g","threadId":"65574","inReplyTo":"a6caa5b397da8ea24eb97e6aa6dc92b437e456ef.1784053493.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-19T03:02:19Z","receivedAt":"2026-07-19T03:02:22Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> +struct spare_data {\n> +\tstruct strset *deletable;\n> +\tstruct strset *spared;\n> +};\n> +\n> +/*\n> + * A surviving branch stacked on a deletion candidate would lose its\n> + * upstream, so drop that candidate from the delete set and remember it\n> + * in \"spared\" so its own upstream can be tidied up afterwards.\n> + */\n> +static int spare_stacked_base(const struct reference *ref, void *cb_data)\n> +{\n> +\tstruct spare_data *data = cb_data;\n> +\tstruct branch *branch;\n> +\tconst char *upstream, *up_short;\n> +\n> +\tif (strset_contains(data->deletable, ref->name))\n> +\t\treturn 0;\n> +\tbranch = branch_get(ref->name);\n> +\tupstream = branch_get_upstream(branch, NULL);\n> +\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n> +\t    !strset_contains(data->deletable, up_short))\n> +\t\treturn 0;\n> +\n> +\tstrset_remove(data->deletable, up_short);\n> +\tstrset_add(data->spared, up_short);\n> +\treturn 0;\n> +}\n> +\n> +/*\n> + * Keep any branch that a surviving branch tracks as its upstream, so we\n> + * never delete a branch out from under one stacked on top of it.  Such a\n> + * base is itself merged, so when its own upstream is also going away\n> + * (no surviving branch tracks it), clear the base's now-stale upstream.\n> + */\n> +static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n> +{\n> +\tstruct strset spared = STRSET_INIT;\n> +\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n> +\tstruct strbuf key = STRBUF_INIT;\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *entry;\n> +\n> +\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n\nHmph.  Wouldn't this implicitly make whether a stacked branch is\nspared or has its upstream configuration cleared depends on the\norder in which the branches are visited by the callback function of\nrefs_for_each_branch_ref(), which presumably is alphabetical?\n\nFor example, if 'a_tip' (unmerged) tracks 'b_mid' (merged), which in\nturn tracks 'c_lower' (merged), visiting them in alphabetical order\n('a_tip', 'b_mid', 'c_lower') would spare both 'b_mid' and\n'c_lower'.  If they, however, were named 'tip' (unmerged), which\ntracks 'mid' (merged), which in turn tracks 'lower' (merged), they\nwould be visited in the order 'lower', 'mid', 'tip'.  This would\nresult in 'lower' being deleted and 'mid' being spared with its\nupstream configuration cleared, even though the relationship among\nthese three branches is exactly the same.\n\nSince the branches are visited in a fixed alphabetical order, it\nmight not be an unpredictable order that yields unrepeatable\nresults.  Nonetheless, the behavior should be consistent and\nindependent of branch names, as long as the inter-relationship\namong the branches involved is identical, no?\n\nOr am I grossly misreading the code?\n\nThanks.\n"},{"id":"548626","messageId":"CAHwyqnXdaPeO12+p=_+_ttrknV0-VqTMnH-suS66yZ4stsBKnQ@mail.gmail.com","threadId":"65574","inReplyTo":"xmqqtspvptqc.fsf@gitster.g","subject":"Re: [PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-19T15:30:11Z","receivedAt":"2026-07-19T15:30:52Z","isPatch":true,"body":"I think I can fix this with\n\n```\n    -+  if (strset_contains(data->deletable, ref->name))\n    ++  if (strset_contains(data->deletable, ref->name) ||\n    ++      strset_contains(data->spared, ref->name))\n```\n\nI used your example to write a test about it as well.\n\n\nHarald\n"},{"id":"548630","messageId":"xmqqik6an5t3.fsf@gitster.g","threadId":"65574","inReplyTo":"CAHwyqnXdaPeO12+p=_+_ttrknV0-VqTMnH-suS66yZ4stsBKnQ@mail.gmail.com","subject":"Re: [PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-19T19:22:00Z","receivedAt":"2026-07-19T19:22:04Z","isPatch":true,"body":"Harald Nordgren <haraldnordgren@gmail.com> writes:\n\n> I think I can fix this with\n>\n> ```\n>     -+  if (strset_contains(data->deletable, ref->name))\n>     ++  if (strset_contains(data->deletable, ref->name) ||\n>     ++      strset_contains(data->spared, ref->name))\n> ```\n>\n> I used your example to write a test about it as well.\n>\n>\n> Harald\n\nI do not claim that the single example I gave covers all the issues\nthat arise from failing to analyze the dependency graph, or from\nattempting to solve the problem sequentially, which makes the\nsolution depend on the order in which branches are visited.\n\nI have a suspicion that it may be unavoidable to employ a multi-pass\napproach that iteratively identifies all branches transitively\nneeded by any surviving branch, though that is merely a hunch,\nunsupported by any proof.\n\nThanks.\n"},{"id":"548632","messageId":"9b9b9a2c-dd0f-44f8-b80e-565eed9a55a8@gmail.com","threadId":"65574","inReplyTo":"xmqqik6an5t3.fsf@gitster.g","subject":"Re: [PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-19T19:42:29Z","receivedAt":"2026-07-19T19:42:38Z","isPatch":true,"body":"On 19/07/2026 20:22, Junio C Hamano wrote:\n> Harald Nordgren <haraldnordgren@gmail.com> writes:\n> \n>> I think I can fix this with\n>>\n>> ```\n>>      -+  if (strset_contains(data->deletable, ref->name))\n>>      ++  if (strset_contains(data->deletable, ref->name) ||\n>>      ++      strset_contains(data->spared, ref->name))\n>> ```\n>>\n>> I used your example to write a test about it as well.\n>>\n>>\n>> Harald\n> \n> I do not claim that the single example I gave covers all the issues\n> that arise from failing to analyze the dependency graph, or from\n> attempting to solve the problem sequentially, which makes the\n> solution depend on the order in which branches are visited.\n> \n> I have a suspicion that it may be unavoidable to employ a multi-pass\n> approach that iteratively identifies all branches transitively\n> needed by any surviving branch, though that is merely a hunch,\n> unsupported by any proof.\n\nI think it would certainly be easier to reason about if we did not \nmutate data->deletable in spare_stacked_base() in removed the spared \nbranches with\n\n\tstrset_for_each_entry(&spared, &iter, entry)\n\t\tstrset_remove(&deletable, entry);\n\nafter the call to refs_for_each_branch_ref() returns in \nspare_stacked_branches().\n\nI'll try and have a proper look at this iteration in the comming week.\n\nThanks\n\nPhillip\n\n"},{"id":"548638","messageId":"xmqqpl0ilkpq.fsf@gitster.g","threadId":"65574","inReplyTo":"a6caa5b397da8ea24eb97e6aa6dc92b437e456ef.1784053493.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v19 5/7] branch: add --delete-merged <branch>","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-19T21:42:57Z","receivedAt":"2026-07-19T21:43:00Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> ...\n> +struct spare_data {\n> +\tstruct strset *deletable;\n> +\tstruct strset *spared;\n> +};\n\nLet me offer a brief comment on the data representation chosen for\nthis design, which initially left me confused enough to suspect a\nbug or two.  It turns out the confusion was entirely mine, and I\nhave since convinced myself that the approach is sound.\n\n> +/*\n> + * A surviving branch stacked on a deletion candidate would lose its\n> + * upstream, so drop that candidate from the delete set and remember it\n> + * in \"spared\" so its own upstream can be tidied up afterwards.\n> + */\n> +static int spare_stacked_base(const struct reference *ref, void *cb_data)\n> +{\n> +\tstruct spare_data *data = cb_data;\n> +\tstruct branch *branch;\n> +\tconst char *upstream, *up_short;\n> +\n> +\tif (strset_contains(data->deletable, ref->name))\n> +\t\treturn 0;\n> +\tbranch = branch_get(ref->name);\n\nHere, spare_stacked_base() is a callback triggered by the\nrefs_for_each_branch_ref() iterator.  I initially misremembered what\nthe for-each-ref family of iterators passes to its callbacks.  I\nthought 'ref->name' here would be a full refname, such as\n'refs/heads/main', which does not match what 'branch_get' expects\n(which is a branch name).  The same confusion led me to think the\n'deletable' strset was indexed by full refnames, which would then\n...\n\n> +\tupstream = branch_get_upstream(branch, NULL);\n> +\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n> +\t    !strset_contains(data->deletable, up_short))\n> +\t\treturn 0;\n\n... mean that this lookup using 'up_short' (the branch name obtained\nafter stripping the 'refs/heads/' prefix) is buggy.  But that is not\nthe case.  The 'deletable' strset stores branch names, so indexing\nwith 'up_short' is correct, and ...\n\n> +\tstrset_remove(data->deletable, up_short);\n> +\tstrset_add(data->spared, up_short);\n\n... adding 'up_short' to the 'deletable' strset is correct too.  The\nsame applies to the 'spared' strset.  It is consistently indexed by\nbranch names rather than full refnames.\n\nThis design choice makes perfect sense for this application.  We\nhave no business touching the upstream of a branch unless it is a\nlocal branch.  A remote-tracking branch, such as\n'refs/remotes/origin/main', lives outside the 'refs/heads/'\nhierarchy.  Such a branch has no need to interact with either the\n'deletable' or 'spared' tables.\n\nI hope others will not be as easily confused as I was, but just in\ncase it helps future readers ...\n\nThanks.\n\n"},{"id":"548755","messageId":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v19.git.git.1784053493.gitgitgadget@gmail.com","subject":"[PATCH v20 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:31Z","receivedAt":"2026-07-22T07:10:41Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  49 +++-\n builtin/branch.c                 | 276 +++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 393 +++++++++++++++++++++++++++++++\n 6 files changed, 774 insertions(+), 31 deletions(-)\n\n\nbase-commit: 5d2e7709234afea1b6ddb25cd4f60d3d5fb3c200\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v20\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v20\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v19:\n\n 1:  562648132d = 1:  51c1c9d075 branch: add --forked filter for --list mode\n 2:  c7ebd9344c = 2:  711574b2e5 branch: convert delete_branches() to a flags argument\n 3:  0c4f3358e3 = 3:  47c5975dc7 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  64a202526a = 4:  46268acec5 branch: prepare delete_branches for a bulk caller\n 5:  a6caa5b397 ! 5:  ef9f57e735 branch: add --delete-merged <branch>\n     @@ Commit message\n          A branch whose work is not yet merged into its upstream is silently\n          skipped, so one unmerged topic does not abort the whole sweep.\n      \n     -    A branch that another, surviving branch tracks as its upstream is\n     -    also kept, so a branch is never deleted out from under one stacked\n     -    on top of it. Such a kept branch is itself merged, so when its own\n     -    upstream is being deleted, clear its now-stale upstream config.\n     +    A branch that a surviving branch depends on through a chain of local\n     +    upstreams is also kept, so no branch is deleted out from under stacked\n     +    work. Collect this transitive set without changing the candidate set\n     +    during ref iteration: walk upstream chains from surviving branches,\n     +    visit each branch at most once, and remove the collected bases only\n     +    after the iteration completes. This makes the result independent of\n     +    ref iteration order without repeated full scans.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n      +silently skipped. Delete it with `git branch -D` if you want to\n      +remove it anyway.\n      ++\n     -+A branch that another, surviving branch tracks as its upstream is\n     -+kept, so a branch is never deleted out from under one stacked on top\n     -+of it. If that kept branch in turn tracks a branch that is being\n     -+deleted, its now-stale upstream configuration is cleared.\n     ++A branch that a surviving branch depends on through a chain of local\n     ++upstreams is kept, so a branch is never deleted out from under stacked\n     ++work.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n       \treturn 0;\n       }\n       \n     -+struct spare_data {\n     -+\tstruct strset *deletable;\n     -+\tstruct strset *spared;\n     ++struct stacked_branch_data {\n     ++\tstruct strset *deletable_branch_names;\n     ++\tstruct strset *protected_branch_names;\n     ++\tstruct strset *visited_branch_names;\n      +};\n      +\n     -+/*\n     -+ * A surviving branch stacked on a deletion candidate would lose its\n     -+ * upstream, so drop that candidate from the delete set and remember it\n     -+ * in \"spared\" so its own upstream can be tidied up afterwards.\n     -+ */\n     -+static int spare_stacked_base(const struct reference *ref, void *cb_data)\n     ++static int collect_stacked_branch_bases(const struct reference *ref,\n     ++\t\t\t\t\tvoid *cb_data)\n      +{\n     -+\tstruct spare_data *data = cb_data;\n     -+\tstruct branch *branch;\n     -+\tconst char *upstream, *up_short;\n     ++\tstruct stacked_branch_data *data = cb_data;\n     ++\tconst char *branch_name;\n      +\n     -+\tif (strset_contains(data->deletable, ref->name))\n     -+\t\treturn 0;\n     -+\tbranch = branch_get(ref->name);\n     -+\tupstream = branch_get_upstream(branch, NULL);\n     -+\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     -+\t    !strset_contains(data->deletable, up_short))\n     ++\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n     ++\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n     ++\tif (strset_contains(data->deletable_branch_names, branch_name))\n      +\t\treturn 0;\n      +\n     -+\tstrset_remove(data->deletable, up_short);\n     -+\tstrset_add(data->spared, up_short);\n     ++\twhile (strset_add(data->visited_branch_names, branch_name)) {\n     ++\t\tstruct branch *branch = branch_get(branch_name);\n     ++\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n     ++\t\tconst char *upstream_branch_name;\n     ++\n     ++\t\tif (!upstream_refname ||\n     ++\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n     ++\t\t\t\t &upstream_branch_name) ||\n     ++\t\t    !strset_contains(data->deletable_branch_names,\n     ++\t\t\t\t    upstream_branch_name))\n     ++\t\t\tbreak;\n     ++\n     ++\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n     ++\t\tbranch_name = upstream_branch_name;\n     ++\t}\n     ++\n      +\treturn 0;\n      +}\n      +\n     -+/*\n     -+ * Keep any branch that a surviving branch tracks as its upstream, so we\n     -+ * never delete a branch out from under one stacked on top of it.  Such a\n     -+ * base is itself merged, so when its own upstream is also going away\n     -+ * (no surviving branch tracks it), clear the base's now-stale upstream.\n     -+ */\n     -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n     ++static void protect_stacked_branch_bases(struct ref_store *refs,\n     ++\t\t\t\t\t struct strset *deletable_branch_names)\n      +{\n     -+\tstruct strset spared = STRSET_INIT;\n     -+\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n     -+\tstruct strbuf key = STRBUF_INIT;\n     ++\tstruct strset protected_branch_names = STRSET_INIT;\n     ++\tstruct strset visited_branch_names = STRSET_INIT;\n     ++\tstruct stacked_branch_data data = {\n     ++\t\t.deletable_branch_names = deletable_branch_names,\n     ++\t\t.protected_branch_names = &protected_branch_names,\n     ++\t\t.visited_branch_names = &visited_branch_names,\n     ++\t};\n     ++\tstruct refs_for_each_ref_options opts = {\n     ++\t\t.prefix = \"refs/heads/\",\n     ++\t};\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n      +\n     -+\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n     -+\n     -+\tstrset_for_each_entry(&spared, &iter, entry) {\n     -+\t\tstruct branch *branch = branch_get(entry->key);\n     -+\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     -+\t\tconst char *up_short;\n     -+\n     -+\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     -+\t\t    !strset_contains(deletable, up_short))\n     -+\t\t\tcontinue;\n     ++\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n      +\n     -+\t\tstrbuf_reset(&key);\n     -+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     -+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     -+\t\tstrbuf_reset(&key);\n     -+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     -+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     -+\t}\n     ++\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n     ++\t\tstrset_remove(deletable_branch_names, entry->key);\n      +\n     -+\tstrbuf_release(&key);\n     -+\tstrset_clear(&spared);\n     ++\tstrset_clear(&visited_branch_names);\n     ++\tstrset_clear(&protected_branch_names);\n      +}\n      +\n      +static int branch_pushes_to_upstream(struct branch *branch,\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n      +\tstruct ref_array candidates = { 0 };\n     -+\tstruct strset deletable = STRSET_INIT;\n     -+\tstruct strvec to_delete = STRVEC_INIT;\n     ++\tstruct strset deletable_branch_names = STRSET_INIT;\n     ++\tstruct strvec branches_to_delete = STRVEC_INIT;\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n      +\tsize_t i;\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tfilter_refs(&candidates, &filter, filter.kind);\n      +\n      +\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n     -+\t\tconst char *full_name = candidates.items[i]->refname;\n     -+\t\tconst char *short_name;\n     ++\t\tconst char *branch_refname = candidates.items[i]->refname;\n     ++\t\tconst char *branch_name;\n      +\t\tstruct branch *branch;\n     -+\t\tconst char *upstream;\n     ++\t\tconst char *upstream_refname;\n      +\n     -+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     -+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n     -+\t\tif (branch_checked_out(full_name))\n     ++\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n     ++\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n     ++\t\tif (branch_checked_out(branch_refname))\n      +\t\t\tcontinue;\n      +\n     -+\t\tbranch = branch_get(short_name);\n     -+\t\tupstream = branch_get_upstream(branch, NULL);\n     -+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n     ++\t\tbranch = branch_get(branch_name);\n     ++\t\tupstream_refname = branch_get_upstream(branch, NULL);\n     ++\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n      +\t\t\tcontinue;\n     -+\t\tif (branch_pushes_to_upstream(branch, upstream))\n     ++\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n      +\t\t\tcontinue;\n     -+\t\tif (check_branch_commit(short_name, short_name,\n     ++\t\tif (check_branch_commit(branch_name, branch_name,\n      +\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n      +\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n      +\t\t\tcontinue;\n      +\n     -+\t\tstrset_add(&deletable, short_name);\n     ++\t\tstrset_add(&deletable_branch_names, branch_name);\n      +\t}\n      +\n     -+\tspare_stacked_bases(refs, &deletable);\n     ++\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n      +\n     -+\tstrset_for_each_entry(&deletable, &iter, entry)\n     -+\t\tstrvec_push(&to_delete, entry->key);\n     ++\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n     ++\t\tstrvec_push(&branches_to_delete, entry->key);\n      +\n     -+\tif (to_delete.nr)\n     -+\t\tret = delete_branches(to_delete.nr, to_delete.v,\n     ++\tif (branches_to_delete.nr)\n     ++\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n      +\t\t\t\t      FILTER_REFS_BRANCHES,\n      +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n      +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n      +\t\t\t\t      flags);\n      +\n     -+\tstrvec_clear(&to_delete);\n     -+\tstrset_clear(&deletable);\n     ++\tstrvec_clear(&branches_to_delete);\n     ++\tstrset_clear(&deletable_branch_names);\n      +\tref_array_clear(&candidates);\n      +\tref_filter_clear(&filter);\n      +\treturn ret;\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '\n     ++test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n      +\tsetup_repo_for_delete_merged &&\n      +\t(\n      +\t\tcd repo &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t\tgit commit --allow-empty -m \"tip work\" &&\n      +\n      +\t\tgit branch --delete-merged origin/next \\\n     -+\t\t\t--delete-merged lower &&\n     ++\t\t\t--delete-merged lower >actual 2>&1 &&\n     ++\t\ttest_must_be_empty actual &&\n      +\n      +\t\tcheck_branches <<-\\EOF &&\n     ++\t\tlower\n      +\t\tmain\n      +\t\tmid\n      +\t\ttip\n      +\t\tEOF\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n     ++\t\tbranch.lower.remote origin\n     ++\t\tbranch.lower.merge refs/heads/next\n     ++\t\tbranch.mid.remote .\n     ++\t\tbranch.mid.merge refs/heads/lower\n      +\t\tbranch.tip.remote .\n      +\t\tbranch.tip.merge refs/heads/mid\n      +\t\tEOF\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t)\n      +'\n      +\n     ++test_expect_success '--delete-merged result is independent of stacked branch names' '\n     ++\tsetup_repo_for_delete_merged &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit branch c-lower origin/next --track &&\n     ++\t\tgit branch b-mid c-lower --track &&\n     ++\t\tgit checkout -b a-tip b-mid --track &&\n     ++\t\tgit commit --allow-empty -m \"tip work\" &&\n     ++\n     ++\t\tgit branch --delete-merged origin/next \\\n     ++\t\t\t--delete-merged \"c-*\" &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF &&\n     ++\t\ta-tip\n     ++\t\tb-mid\n     ++\t\tc-lower\n     ++\t\tmain\n     ++\t\tEOF\n     ++\n     ++\t\tgit branch --delete-merged origin/next \\\n     ++\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n     ++\t\ttest_must_be_empty actual &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\ta-tip\n     ++\t\tb-mid\n     ++\t\tc-lower\n     ++\t\tmain\n     ++\t\tEOF\n     ++\t)\n     ++'\n     ++\n      +test_expect_success '--delete-merged requires a value' '\n      +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n      +\ttest_grep \"requires a value\" err\n 6:  734d27c908 ! 6:  fa70108611 branch: add branch.<name>.deleteMerged opt-out\n     @@ Documentation/git-branch.adoc: A branch is not deleted when:\n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \tstruct ref_array candidates = { 0 };\n     - \tstruct strset deletable = STRSET_INIT;\n     - \tstruct strvec to_delete = STRVEC_INIT;\n     + \tstruct strset deletable_branch_names = STRSET_INIT;\n     + \tstruct strvec branches_to_delete = STRVEC_INIT;\n      +\tstruct strbuf key = STRBUF_INIT;\n       \tstruct hashmap_iter iter;\n       \tstruct strmap_entry *entry;\n       \tsize_t i;\n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     - \t\tconst char *short_name;\n     + \t\tconst char *branch_name;\n       \t\tstruct branch *branch;\n     - \t\tconst char *upstream;\n     + \t\tconst char *upstream_refname;\n      +\t\tint opt_out;\n       \n     - \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n     - \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n     + \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n     + \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n       \t\t\tcontinue;\n       \n      +\t\tstrbuf_reset(&key);\n     -+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n     ++\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n      +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n      +\t\t    !opt_out) {\n      +\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n      +\t\t\t\tfprintf(stderr,\n      +\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n     -+\t\t\t\t\tshort_name, short_name);\n     ++\t\t\t\t\tbranch_name, branch_name);\n      +\t\t\tcontinue;\n      +\t\t}\n      +\n     - \t\tstrset_add(&deletable, short_name);\n     + \t\tstrset_add(&deletable_branch_names, branch_name);\n       \t}\n       \n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea\n       \t\t\t\t      flags);\n       \n      +\tstrbuf_release(&key);\n     - \tstrvec_clear(&to_delete);\n     - \tstrset_clear(&deletable);\n     + \tstrvec_clear(&branches_to_delete);\n     + \tstrset_clear(&deletable_branch_names);\n       \tref_array_clear(&candidates);\n      \n       ## t/t3200-branch.sh ##\n 7:  7aa9d5db14 ! 7:  13bac431a3 branch: add --dry-run for --delete-merged\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top\n     - of it. If that kept branch in turn tracks a branch that is being\n     - deleted, its now-stale upstream configuration is cleared.\n     +@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local\n     + upstreams is kept, so a branch is never deleted out from under stacked\n     + work.\n       \n      +`--dry-run`::\n      +\tWith `--delete-merged`, print which branches would be\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n       \t\t\tchar *refname = name + branch_name_pos;\n       \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n       \t\t\t\tprintf(remote_branch\n     -@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)\n     -  * base is itself merged, so when its own upstream is also going away\n     -  * (no surviving branch tracks it), clear the base's now-stale upstream.\n     -  */\n     --static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n     -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,\n     -+\t\t\t\tunsigned int flags)\n     - {\n     - \tstruct strset spared = STRSET_INIT;\n     - \tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n     -@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable\n     - \n     - \trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n     - \n     --\tstrset_for_each_entry(&spared, &iter, entry) {\n     --\t\tstruct branch *branch = branch_get(entry->key);\n     --\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     --\t\tconst char *up_short;\n     -+\tif (!(flags & DELETE_BRANCH_DRY_RUN)) {\n     -+\t\tstrset_for_each_entry(&spared, &iter, entry) {\n     -+\t\t\tstruct branch *branch = branch_get(entry->key);\n     -+\t\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n     -+\t\t\tconst char *up_short;\n     - \n     --\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     --\t\t    !strset_contains(deletable, up_short))\n     --\t\t\tcontinue;\n     -+\t\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n     -+\t\t\t    !strset_contains(deletable, up_short))\n     -+\t\t\t\tcontinue;\n     - \n     --\t\tstrbuf_reset(&key);\n     --\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     --\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     --\t\tstrbuf_reset(&key);\n     --\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     --\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     -+\t\t\tstrbuf_reset(&key);\n     -+\t\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     -+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     -+\t\t\tstrbuf_reset(&key);\n     -+\t\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     -+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     -+\t\t}\n     - \t}\n     - \n     - \tstrbuf_release(&key);\n     -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     - \t\tstrset_add(&deletable, short_name);\n     - \t}\n     - \n     --\tspare_stacked_bases(refs, &deletable);\n     -+\tspare_stacked_bases(refs, &deletable, flags);\n     - \n     - \tstrset_for_each_entry(&deletable, &iter, entry)\n     - \t\tstrvec_push(&to_delete, entry->key);\n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a\n       \t\tcheck_branches <<-\\EOF &&\n       \t\tfeature\n       \t\tmain\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n       \t\tgit checkout -b tip mid --track &&\n       \t\tgit commit --allow-empty -m \"tip work\" &&\n       \n      +\t\tgit branch --dry-run --delete-merged origin/next \\\n     -+\t\t\t--delete-merged lower &&\n     ++\t\t\t--delete-merged lower >actual 2>&1 &&\n     ++\t\ttest_must_be_empty actual &&\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n     ++\t\tbranch.lower.remote origin\n     ++\t\tbranch.lower.merge refs/heads/next\n      +\t\tbranch.mid.remote .\n      +\t\tbranch.mid.merge refs/heads/lower\n      +\t\tbranch.tip.remote .\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr\n      +\t\ttest_cmp expect actual &&\n      +\n       \t\tgit branch --delete-merged origin/next \\\n     - \t\t\t--delete-merged lower &&\n     - \n     + \t\t\t--delete-merged lower >actual 2>&1 &&\n     + \t\ttest_must_be_empty actual &&\n      @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n       \t)\n       '\n\n-- \ngitgitgadget\n"},{"id":"548756","messageId":"51c1c9d07521ff8de7e0faa1287bad0c39ff0d4e.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:32Z","receivedAt":"2026-07-22T07:10:42Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 ++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 224 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dede60d27b..3ac1272d7e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -793,6 +803,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -838,7 +851,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 284796c49b..cbdac1a19a 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1ecbafbee1..4ffd224a71 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1755,4 +1755,121 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548757","messageId":"711574b2e5e6fad528713b62b415e22266ede080.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:33Z","receivedAt":"2026-07-22T07:10:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 3ac1272d7e..09631f93f7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -895,7 +901,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"548758","messageId":"47c5975dc751ae0f3a9e33ad4ac28b51ea6444ca.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:34Z","receivedAt":"2026-07-22T07:10:44Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 09631f93f7..504117d1c3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"548759","messageId":"46268acec55a42f5220262635937903eb14a7efe.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:35Z","receivedAt":"2026-07-22T07:10:45Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 504117d1c3..1ef8362c12 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"548762","messageId":"ef9f57e73577b2278c2a0aa20c3d30a5db4cdbb3.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:36Z","receivedAt":"2026-07-22T07:10:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <branch>)... [<pattern>...]\n\ndeletes local branches matching the optional patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nThe option can be repeated to widen the upstream match. Keeping the\ncandidate patterns as positional arguments lets users bound the set of\nlocal branches that may be deleted independently of the upstream\nselection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it by name to the remote configured by\n    branch.<name>.remote would update its upstream, as determined by\n    mapping the branch ref through that remote's fetch refspec. For\n    example, a local \"main\" that tracks \"origin/main\" is kept even when\n    remote.pushDefault names a fork. Right after a pull it merely looks\n    fully merged.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that a surviving branch depends on through a chain of local\nupstreams is also kept, so no branch is deleted out from under stacked\nwork. Collect this transitive set without changing the candidate set\nduring ref iteration: walk upstream chains from surviving branches,\nvisit each branch at most once, and remove the collected bases only\nafter the iteration completes. This makes the result independent of\nref iteration order without repeated full scans.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  30 +++++\n builtin/branch.c              | 159 +++++++++++++++++++++++++-\n t/t3200-branch.sh             | 204 ++++++++++++++++++++++++++++++++++\n 3 files changed, 391 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..2a96cd7253 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<branch>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. The option can be repeated to widen the upstream match.\n+\tOptional _<pattern>_ arguments limit which local branches are\n+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree, or\n+* pushing it by name to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks \"fully merged\" right\n+  after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that a surviving branch depends on through a chain of local\n+upstreams is kept, so a branch is never deleted out from under stacked\n+work.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ef8362c12..b97315df35 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n \tNULL\n };\n \n@@ -699,6 +701,149 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+\tstruct strset *visited_branch_names;\n+};\n+\n+static int collect_stacked_branch_bases(const struct reference *ref,\n+\t\t\t\t\tvoid *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n+\t\tstruct branch *branch = branch_get(branch_name);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tbreak;\n+\n+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\t\tbranch_name = upstream_branch_name;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names)\n+{\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strset visited_branch_names = STRSET_INIT;\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = &protected_branch_names,\n+\t\t.visited_branch_names = &visited_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n+\n+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+\n+\tstrset_clear(&visited_branch_names);\n+\tstrset_clear(&protected_branch_names);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (remote)\n+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tsize_t i;\n+\tint ret = 0;\n+\n+\tfor (i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -763,6 +908,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -816,6 +962,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -863,7 +1012,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -873,7 +1023,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -915,6 +1065,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1083,6 +1237,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4ffd224a71..268203089b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" origin/next --track &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next --track &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete main --track &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* \\\n+\t\t\t--delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature --track &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit branch lower origin/next --track &&\n+\t\tgit branch mid lower --track &&\n+\t\tgit checkout -b tip mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tlower\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch c-lower origin/next --track &&\n+\t\tgit branch b-mid c-lower --track &&\n+\t\tgit checkout -b a-tip b-mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548760","messageId":"fa7010861140047be42cb01a0200dfcb24c7d4e2.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:37Z","receivedAt":"2026-07-22T07:10:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 60 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2a96cd7253..2b206e8689 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,11 +216,12 @@ A branch is not deleted when:\n +\n --\n * its configured upstream ref no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * pushing it by name to the remote configured by\n   `branch.<name>.remote` would update its upstream, so it cannot be\n   distinguished from a branch that just looks \"fully merged\" right\n-  after a pull.\n+  after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b97315df35..6573ad7027 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tsize_t i;\n@@ -805,6 +806,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -822,6 +824,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -837,6 +850,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&deletable_branch_names);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 268203089b..7111306150 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548761","messageId":"13bac431a30f1302b2466b7a81ee95d86188f737.1784704238.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v20 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-22T07:10:38Z","receivedAt":"2026-07-22T07:10:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 21 ++++++++++++++++---\n t/t3200-branch.sh             | 38 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 62 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2b206e8689..51dda15114 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <branch>)... [<pattern>...]\n+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local\n upstreams is kept, so a branch is never deleted out from under stacked\n work.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 6573ad7027..237eadb401 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -340,13 +341,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -923,6 +931,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -979,6 +988,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1041,6 +1052,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1081,7 +1095,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 7111306150..eb1c57a5ca 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other other/main --track &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic feature --track &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n \t\tgit checkout -b tip mid --track &&\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower >actual 2>&1 &&\n \t\ttest_must_be_empty actual &&\n@@ -2112,4 +2143,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"548779","messageId":"20976edc-fc9d-4d4d-8919-b599d336f9cc@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"Re: [PATCH v20 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-22T13:39:43Z","receivedAt":"2026-07-22T13:39:51Z","isPatch":true,"body":"Hi Harald\n\nOn 22/07/2026 08:10, Harald Nordgren via GitGitGadget wrote:\n> Delete branches that have already been merged on upstream.\n> \n> Changes in v20:\n> \n>   * Protect branches transitively required by a surviving local upstream\n>     stack. Traverse upstream chains once and defer delete-set mutation until\n>     traversal completes.\n>   * Make stacked-branch handling independent of ref iteration order and\n>     update the documentation accordingly.\n>   * Clarify variable names with regards to branch names (short) to reduce\n>     confusion.\n\nI'm having a hard time reading the range diff due to the renaming of the \nmembers of \"struct stacked_branch_data\". Can you explain what has \nchanged in the logic to protect branches that are upstreams of unmerged \nbranches and why please? In particular why wasn't sufficient to stop \nremoving the members of \"spared\" from \"deletable\" in \nspare_stacked_base() and remove them after refs_for_each_branch_ref() \nhas returned instead.\n\nThanks\n\nPhillip\n\n> Range-diff vs v19:\n> \n>   1:  562648132d = 1:  51c1c9d075 branch: add --forked filter for --list mode\n>   2:  c7ebd9344c = 2:  711574b2e5 branch: convert delete_branches() to a flags argument\n>   3:  0c4f3358e3 = 3:  47c5975dc7 branch: let delete_branches skip unmerged branches on bulk refusal\n>   4:  64a202526a = 4:  46268acec5 branch: prepare delete_branches for a bulk caller\n>   5:  a6caa5b397 ! 5:  ef9f57e735 branch: add --delete-merged <branch>\n>       @@ Commit message\n>            A branch whose work is not yet merged into its upstream is silently\n>            skipped, so one unmerged topic does not abort the whole sweep.\n>        \n>       -    A branch that another, surviving branch tracks as its upstream is\n>       -    also kept, so a branch is never deleted out from under one stacked\n>       -    on top of it. Such a kept branch is itself merged, so when its own\n>       -    upstream is being deleted, clear its now-stale upstream config.\n>       +    A branch that a surviving branch depends on through a chain of local\n>       +    upstreams is also kept, so no branch is deleted out from under stacked\n>       +    work. Collect this transitive set without changing the candidate set\n>       +    during ref iteration: walk upstream chains from surviving branches,\n>       +    visit each branch at most once, and remove the collected bases only\n>       +    after the iteration completes. This makes the result independent of\n>       +    ref iteration order without repeated full scans.\n>        \n>            Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n>        \n>       @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n>        +silently skipped. Delete it with `git branch -D` if you want to\n>        +remove it anyway.\n>        ++\n>       -+A branch that another, surviving branch tracks as its upstream is\n>       -+kept, so a branch is never deleted out from under one stacked on top\n>       -+of it. If that kept branch in turn tracks a branch that is being\n>       -+deleted, its now-stale upstream configuration is cleared.\n>       ++A branch that a surviving branch depends on through a chain of local\n>       ++upstreams is kept, so a branch is never deleted out from under stacked\n>       ++work.\n>        +\n>         `-v`::\n>         `-vv`::\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>         \treturn 0;\n>         }\n>         \n>       -+struct spare_data {\n>       -+\tstruct strset *deletable;\n>       -+\tstruct strset *spared;\n>       ++struct stacked_branch_data {\n>       ++\tstruct strset *deletable_branch_names;\n>       ++\tstruct strset *protected_branch_names;\n>       ++\tstruct strset *visited_branch_names;\n>        +};\n>        +\n>       -+/*\n>       -+ * A surviving branch stacked on a deletion candidate would lose its\n>       -+ * upstream, so drop that candidate from the delete set and remember it\n>       -+ * in \"spared\" so its own upstream can be tidied up afterwards.\n>       -+ */\n>       -+static int spare_stacked_base(const struct reference *ref, void *cb_data)\n>       ++static int collect_stacked_branch_bases(const struct reference *ref,\n>       ++\t\t\t\t\tvoid *cb_data)\n>        +{\n>       -+\tstruct spare_data *data = cb_data;\n>       -+\tstruct branch *branch;\n>       -+\tconst char *upstream, *up_short;\n>       ++\tstruct stacked_branch_data *data = cb_data;\n>       ++\tconst char *branch_name;\n>        +\n>       -+\tif (strset_contains(data->deletable, ref->name))\n>       -+\t\treturn 0;\n>       -+\tbranch = branch_get(ref->name);\n>       -+\tupstream = branch_get_upstream(branch, NULL);\n>       -+\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       -+\t    !strset_contains(data->deletable, up_short))\n>       ++\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n>       ++\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n>       ++\tif (strset_contains(data->deletable_branch_names, branch_name))\n>        +\t\treturn 0;\n>        +\n>       -+\tstrset_remove(data->deletable, up_short);\n>       -+\tstrset_add(data->spared, up_short);\n>       ++\twhile (strset_add(data->visited_branch_names, branch_name)) {\n>       ++\t\tstruct branch *branch = branch_get(branch_name);\n>       ++\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n>       ++\t\tconst char *upstream_branch_name;\n>       ++\n>       ++\t\tif (!upstream_refname ||\n>       ++\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n>       ++\t\t\t\t &upstream_branch_name) ||\n>       ++\t\t    !strset_contains(data->deletable_branch_names,\n>       ++\t\t\t\t    upstream_branch_name))\n>       ++\t\t\tbreak;\n>       ++\n>       ++\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n>       ++\t\tbranch_name = upstream_branch_name;\n>       ++\t}\n>       ++\n>        +\treturn 0;\n>        +}\n>        +\n>       -+/*\n>       -+ * Keep any branch that a surviving branch tracks as its upstream, so we\n>       -+ * never delete a branch out from under one stacked on top of it.  Such a\n>       -+ * base is itself merged, so when its own upstream is also going away\n>       -+ * (no surviving branch tracks it), clear the base's now-stale upstream.\n>       -+ */\n>       -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n>       ++static void protect_stacked_branch_bases(struct ref_store *refs,\n>       ++\t\t\t\t\t struct strset *deletable_branch_names)\n>        +{\n>       -+\tstruct strset spared = STRSET_INIT;\n>       -+\tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n>       -+\tstruct strbuf key = STRBUF_INIT;\n>       ++\tstruct strset protected_branch_names = STRSET_INIT;\n>       ++\tstruct strset visited_branch_names = STRSET_INIT;\n>       ++\tstruct stacked_branch_data data = {\n>       ++\t\t.deletable_branch_names = deletable_branch_names,\n>       ++\t\t.protected_branch_names = &protected_branch_names,\n>       ++\t\t.visited_branch_names = &visited_branch_names,\n>       ++\t};\n>       ++\tstruct refs_for_each_ref_options opts = {\n>       ++\t\t.prefix = \"refs/heads/\",\n>       ++\t};\n>        +\tstruct hashmap_iter iter;\n>        +\tstruct strmap_entry *entry;\n>        +\n>       -+\trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n>       -+\n>       -+\tstrset_for_each_entry(&spared, &iter, entry) {\n>       -+\t\tstruct branch *branch = branch_get(entry->key);\n>       -+\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n>       -+\t\tconst char *up_short;\n>       -+\n>       -+\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       -+\t\t    !strset_contains(deletable, up_short))\n>       -+\t\t\tcontinue;\n>       ++\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n>        +\n>       -+\t\tstrbuf_reset(&key);\n>       -+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n>       -+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       -+\t\tstrbuf_reset(&key);\n>       -+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n>       -+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       -+\t}\n>       ++\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n>       ++\t\tstrset_remove(deletable_branch_names, entry->key);\n>        +\n>       -+\tstrbuf_release(&key);\n>       -+\tstrset_clear(&spared);\n>       ++\tstrset_clear(&visited_branch_names);\n>       ++\tstrset_clear(&protected_branch_names);\n>        +}\n>        +\n>        +static int branch_pushes_to_upstream(struct branch *branch,\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n>        +\tstruct ref_filter filter = REF_FILTER_INIT;\n>        +\tstruct ref_array candidates = { 0 };\n>       -+\tstruct strset deletable = STRSET_INIT;\n>       -+\tstruct strvec to_delete = STRVEC_INIT;\n>       ++\tstruct strset deletable_branch_names = STRSET_INIT;\n>       ++\tstruct strvec branches_to_delete = STRVEC_INIT;\n>        +\tstruct hashmap_iter iter;\n>        +\tstruct strmap_entry *entry;\n>        +\tsize_t i;\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\tfilter_refs(&candidates, &filter, filter.kind);\n>        +\n>        +\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n>       -+\t\tconst char *full_name = candidates.items[i]->refname;\n>       -+\t\tconst char *short_name;\n>       ++\t\tconst char *branch_refname = candidates.items[i]->refname;\n>       ++\t\tconst char *branch_name;\n>        +\t\tstruct branch *branch;\n>       -+\t\tconst char *upstream;\n>       ++\t\tconst char *upstream_refname;\n>        +\n>       -+\t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n>       -+\t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n>       -+\t\tif (branch_checked_out(full_name))\n>       ++\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n>       ++\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n>       ++\t\tif (branch_checked_out(branch_refname))\n>        +\t\t\tcontinue;\n>        +\n>       -+\t\tbranch = branch_get(short_name);\n>       -+\t\tupstream = branch_get_upstream(branch, NULL);\n>       -+\t\tif (!upstream || !refs_ref_exists(refs, upstream))\n>       ++\t\tbranch = branch_get(branch_name);\n>       ++\t\tupstream_refname = branch_get_upstream(branch, NULL);\n>       ++\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n>        +\t\t\tcontinue;\n>       -+\t\tif (branch_pushes_to_upstream(branch, upstream))\n>       ++\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n>        +\t\t\tcontinue;\n>       -+\t\tif (check_branch_commit(short_name, short_name,\n>       ++\t\tif (check_branch_commit(branch_name, branch_name,\n>        +\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n>        +\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n>        +\t\t\tcontinue;\n>        +\n>       -+\t\tstrset_add(&deletable, short_name);\n>       ++\t\tstrset_add(&deletable_branch_names, branch_name);\n>        +\t}\n>        +\n>       -+\tspare_stacked_bases(refs, &deletable);\n>       ++\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n>        +\n>       -+\tstrset_for_each_entry(&deletable, &iter, entry)\n>       -+\t\tstrvec_push(&to_delete, entry->key);\n>       ++\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n>       ++\t\tstrvec_push(&branches_to_delete, entry->key);\n>        +\n>       -+\tif (to_delete.nr)\n>       -+\t\tret = delete_branches(to_delete.nr, to_delete.v,\n>       ++\tif (branches_to_delete.nr)\n>       ++\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n>        +\t\t\t\t      FILTER_REFS_BRANCHES,\n>        +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n>        +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n>        +\t\t\t\t      flags);\n>        +\n>       -+\tstrvec_clear(&to_delete);\n>       -+\tstrset_clear(&deletable);\n>       ++\tstrvec_clear(&branches_to_delete);\n>       ++\tstrset_clear(&deletable_branch_names);\n>        +\tref_array_clear(&candidates);\n>        +\tref_filter_clear(&filter);\n>        +\treturn ret;\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t)\n>        +'\n>        +\n>       -+test_expect_success '--delete-merged clears the deleted upstream of a spared branch' '\n>       ++test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n>        +\tsetup_repo_for_delete_merged &&\n>        +\t(\n>        +\t\tcd repo &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t\tgit commit --allow-empty -m \"tip work\" &&\n>        +\n>        +\t\tgit branch --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged lower &&\n>       ++\t\t\t--delete-merged lower >actual 2>&1 &&\n>       ++\t\ttest_must_be_empty actual &&\n>        +\n>        +\t\tcheck_branches <<-\\EOF &&\n>       ++\t\tlower\n>        +\t\tmain\n>        +\t\tmid\n>        +\t\ttip\n>        +\t\tEOF\n>        +\n>       -+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n>       ++\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n>        +\t\tcat >expect <<-\\EOF &&\n>       ++\t\tbranch.lower.remote origin\n>       ++\t\tbranch.lower.merge refs/heads/next\n>       ++\t\tbranch.mid.remote .\n>       ++\t\tbranch.mid.merge refs/heads/lower\n>        +\t\tbranch.tip.remote .\n>        +\t\tbranch.tip.merge refs/heads/mid\n>        +\t\tEOF\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t)\n>        +'\n>        +\n>       ++test_expect_success '--delete-merged result is independent of stacked branch names' '\n>       ++\tsetup_repo_for_delete_merged &&\n>       ++\t(\n>       ++\t\tcd repo &&\n>       ++\t\tgit branch c-lower origin/next --track &&\n>       ++\t\tgit branch b-mid c-lower --track &&\n>       ++\t\tgit checkout -b a-tip b-mid --track &&\n>       ++\t\tgit commit --allow-empty -m \"tip work\" &&\n>       ++\n>       ++\t\tgit branch --delete-merged origin/next \\\n>       ++\t\t\t--delete-merged \"c-*\" &&\n>       ++\n>       ++\t\tcheck_branches <<-\\EOF &&\n>       ++\t\ta-tip\n>       ++\t\tb-mid\n>       ++\t\tc-lower\n>       ++\t\tmain\n>       ++\t\tEOF\n>       ++\n>       ++\t\tgit branch --delete-merged origin/next \\\n>       ++\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n>       ++\t\ttest_must_be_empty actual &&\n>       ++\n>       ++\t\tcheck_branches <<-\\EOF\n>       ++\t\ta-tip\n>       ++\t\tb-mid\n>       ++\t\tc-lower\n>       ++\t\tmain\n>       ++\t\tEOF\n>       ++\t)\n>       ++'\n>       ++\n>        +test_expect_success '--delete-merged requires a value' '\n>        +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n>        +\ttest_grep \"requires a value\" err\n>   6:  734d27c908 ! 6:  fa70108611 branch: add branch.<name>.deleteMerged opt-out\n>       @@ Documentation/git-branch.adoc: A branch is not deleted when:\n>         ## builtin/branch.c ##\n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>         \tstruct ref_array candidates = { 0 };\n>       - \tstruct strset deletable = STRSET_INIT;\n>       - \tstruct strvec to_delete = STRVEC_INIT;\n>       + \tstruct strset deletable_branch_names = STRSET_INIT;\n>       + \tstruct strvec branches_to_delete = STRVEC_INIT;\n>        +\tstruct strbuf key = STRBUF_INIT;\n>         \tstruct hashmap_iter iter;\n>         \tstruct strmap_entry *entry;\n>         \tsize_t i;\n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       - \t\tconst char *short_name;\n>       + \t\tconst char *branch_name;\n>         \t\tstruct branch *branch;\n>       - \t\tconst char *upstream;\n>       + \t\tconst char *upstream_refname;\n>        +\t\tint opt_out;\n>         \n>       - \t\tif (!skip_prefix(full_name, \"refs/heads/\", &short_name))\n>       - \t\t\tBUG(\"filter returned non-branch ref '%s'\", full_name);\n>       + \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n>       + \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>         \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n>         \t\t\tcontinue;\n>         \n>        +\t\tstrbuf_reset(&key);\n>       -+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", short_name);\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n>        +\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n>        +\t\t    !opt_out) {\n>        +\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n>        +\t\t\t\tfprintf(stderr,\n>        +\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n>       -+\t\t\t\t\tshort_name, short_name);\n>       ++\t\t\t\t\tbranch_name, branch_name);\n>        +\t\t\tcontinue;\n>        +\t\t}\n>        +\n>       - \t\tstrset_add(&deletable, short_name);\n>       + \t\tstrset_add(&deletable_branch_names, branch_name);\n>         \t}\n>         \n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea\n>         \t\t\t\t      flags);\n>         \n>        +\tstrbuf_release(&key);\n>       - \tstrvec_clear(&to_delete);\n>       - \tstrset_clear(&deletable);\n>       + \tstrvec_clear(&branches_to_delete);\n>       + \tstrset_clear(&deletable_branch_names);\n>         \tref_array_clear(&candidates);\n>        \n>         ## t/t3200-branch.sh ##\n>   7:  7aa9d5db14 ! 7:  13bac431a3 branch: add --dry-run for --delete-merged\n>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: kept, so a branch is never deleted out from under one stacked on top\n>       - of it. If that kept branch in turn tracks a branch that is being\n>       - deleted, its now-stale upstream configuration is cleared.\n>       +@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local\n>       + upstreams is kept, so a branch is never deleted out from under stacked\n>       + work.\n>         \n>        +`--dry-run`::\n>        +\tWith `--delete-merged`, print which branches would be\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n>         \t\t\tchar *refname = name + branch_name_pos;\n>         \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n>         \t\t\t\tprintf(remote_branch\n>       -@@ builtin/branch.c: static int spare_stacked_base(const struct reference *ref, void *cb_data)\n>       -  * base is itself merged, so when its own upstream is also going away\n>       -  * (no surviving branch tracks it), clear the base's now-stale upstream.\n>       -  */\n>       --static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable)\n>       -+static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable,\n>       -+\t\t\t\tunsigned int flags)\n>       - {\n>       - \tstruct strset spared = STRSET_INIT;\n>       - \tstruct spare_data data = { .deletable = deletable, .spared = &spared };\n>       -@@ builtin/branch.c: static void spare_stacked_bases(struct ref_store *refs, struct strset *deletable\n>       -\n>       - \trefs_for_each_branch_ref(refs, spare_stacked_base, &data);\n>       -\n>       --\tstrset_for_each_entry(&spared, &iter, entry) {\n>       --\t\tstruct branch *branch = branch_get(entry->key);\n>       --\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n>       --\t\tconst char *up_short;\n>       -+\tif (!(flags & DELETE_BRANCH_DRY_RUN)) {\n>       -+\t\tstrset_for_each_entry(&spared, &iter, entry) {\n>       -+\t\t\tstruct branch *branch = branch_get(entry->key);\n>       -+\t\t\tconst char *upstream = branch_get_upstream(branch, NULL);\n>       -+\t\t\tconst char *up_short;\n>       -\n>       --\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       --\t\t    !strset_contains(deletable, up_short))\n>       --\t\t\tcontinue;\n>       -+\t\t\tif (!upstream || !skip_prefix(upstream, \"refs/heads/\", &up_short) ||\n>       -+\t\t\t    !strset_contains(deletable, up_short))\n>       -+\t\t\t\tcontinue;\n>       -\n>       --\t\tstrbuf_reset(&key);\n>       --\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n>       --\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       --\t\tstrbuf_reset(&key);\n>       --\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n>       --\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       -+\t\t\tstrbuf_reset(&key);\n>       -+\t\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n>       -+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       -+\t\t\tstrbuf_reset(&key);\n>       -+\t\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n>       -+\t\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       -+\t\t}\n>       - \t}\n>       -\n>       - \tstrbuf_release(&key);\n>       -@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       - \t\tstrset_add(&deletable, short_name);\n>       - \t}\n>       -\n>       --\tspare_stacked_bases(refs, &deletable);\n>       -+\tspare_stacked_bases(refs, &deletable, flags);\n>       -\n>       - \tstrset_for_each_entry(&deletable, &iter, entry)\n>       - \t\tstrvec_push(&to_delete, entry->key);\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tint delete = 0, rename = 0, copy = 0, list = 0,\n>         \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a\n>         \t\tcheck_branches <<-\\EOF &&\n>         \t\tfeature\n>         \t\tmain\n>       -@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a spared bra\n>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n>         \t\tgit checkout -b tip mid --track &&\n>         \t\tgit commit --allow-empty -m \"tip work\" &&\n>         \n>        +\t\tgit branch --dry-run --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged lower &&\n>       ++\t\t\t--delete-merged lower >actual 2>&1 &&\n>       ++\t\ttest_must_be_empty actual &&\n>        +\n>       -+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n>       ++\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n>        +\t\tcat >expect <<-\\EOF &&\n>       ++\t\tbranch.lower.remote origin\n>       ++\t\tbranch.lower.merge refs/heads/next\n>        +\t\tbranch.mid.remote .\n>        +\t\tbranch.mid.merge refs/heads/lower\n>        +\t\tbranch.tip.remote .\n>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr\n>        +\t\ttest_cmp expect actual &&\n>        +\n>         \t\tgit branch --delete-merged origin/next \\\n>       - \t\t\t--delete-merged lower &&\n>       -\n>       + \t\t\t--delete-merged lower >actual 2>&1 &&\n>       + \t\ttest_must_be_empty actual &&\n>        @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n>         \t)\n>         '\n> \n\n"},{"id":"548785","messageId":"CAHwyqnUmq1fMC9qEbX+7P7W=TL1d7HaXMDsj2QoLZ+nQqRKOAw@mail.gmail.com","threadId":"65574","inReplyTo":"20976edc-fc9d-4d4d-8919-b599d336f9cc@gmail.com","subject":"Re: [PATCH v20 0/7] branch: delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-22T15:41:06Z","receivedAt":"2026-07-22T15:41:48Z","isPatch":true,"body":"On Wed, Jul 22, 2026 at 3:39 PM Phillip Wood <phillip.wood123@gmail.com> wrote:\n>\n> Hi Harald\n>\n> On 22/07/2026 08:10, Harald Nordgren via GitGitGadget wrote:\n> > Delete branches that have already been merged on upstream.\n> >\n> > Changes in v20:\n> >\n> >   * Protect branches transitively required by a surviving local upstream\n> >     stack. Traverse upstream chains once and defer delete-set mutation until\n> >     traversal completes.\n> >   * Make stacked-branch handling independent of ref iteration order and\n> >     update the documentation accordingly.\n> >   * Clarify variable names with regards to branch names (short) to reduce\n> >     confusion.\n>\n> I'm having a hard time reading the range diff due to the renaming of the\n> members of \"struct stacked_branch_data\". Can you explain what has\n> changed in the logic to protect branches that are upstreams of unmerged\n> branches and why please? In particular why wasn't sufficient to stop\n> removing the members of \"spared\" from \"deletable\" in\n> spare_stacked_base() and remove them after refs_for_each_branch_ref()\n> has returned instead.\n\nHmm. The main idea is to bite the bullet and actually traverse the\ngraph since it didn't seem possible to finish the job correctly in a\nsingle pass anotherwise which I'm trying to demonstrate with\n\n    test_expect_success '--delete-merged keeps the upstream chain of a\nsurviving branch' '\n\nIt becomes a bit like whack-a-mole when Junio asks me to clarify the\ncode and now you cannot review it because of that. I'm still not\nfriends with range-diff even after using it for some time, I agree\nthat the diff v19...v20 doesn't look very inviting.\n\n\nHarald\n"},{"id":"548868","messageId":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v20.git.git.1784704238.gitgitgadget@gmail.com","subject":"[PATCH v21 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:10Z","receivedAt":"2026-07-24T10:36:20Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v21:\n\n * Change loop variables to prevent CI timeout.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  49 +++-\n builtin/branch.c                 | 275 ++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 393 +++++++++++++++++++++++++++++++\n 6 files changed, 773 insertions(+), 31 deletions(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v21\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v21\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v20:\n\n 1:  51c1c9d075 = 1:  90aa528bef branch: add --forked filter for --list mode\n 2:  711574b2e5 = 2:  048c002cb4 branch: convert delete_branches() to a flags argument\n 3:  47c5975dc7 = 3:  bb93303df3 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  46268acec5 = 4:  e70e64c866 branch: prepare delete_branches for a bulk caller\n 5:  ef9f57e735 ! 5:  598e68751e branch: add --delete-merged <branch>\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct strvec branches_to_delete = STRVEC_INIT;\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n     -+\tsize_t i;\n      +\tint ret = 0;\n      +\n     -+\tfor (i = 0; i < upstreams->nr; i++)\n     ++\tfor (size_t i = 0; i < upstreams->nr; i++)\n      +\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n      +\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n      +\t\t\t    upstreams->v[i]);\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tfilter.name_patterns = argv;\n      +\tfilter_refs(&candidates, &filter, filter.kind);\n      +\n     -+\tfor (i = 0; i < (size_t)candidates.nr; i++) {\n     ++\tfor (size_t i = 0; i < (size_t)candidates.nr; i++) {\n      +\t\tconst char *branch_refname = candidates.items[i]->refname;\n      +\t\tconst char *branch_name;\n      +\t\tstruct branch *branch;\n 6:  fa70108611 ! 6:  ac63689d8a branch: add branch.<name>.deleteMerged opt-out\n     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea\n      +\tstruct strbuf key = STRBUF_INIT;\n       \tstruct hashmap_iter iter;\n       \tstruct strmap_entry *entry;\n     - \tsize_t i;\n     + \tint ret = 0;\n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n       \t\tconst char *branch_name;\n       \t\tstruct branch *branch;\n 7:  13bac431a3 = 7:  73c3281b6f branch: add --dry-run for --delete-merged\n\n-- \ngitgitgadget\n"},{"id":"548869","messageId":"90aa528bef242851ca95794f06a9e9311b83a05d.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:11Z","receivedAt":"2026-07-24T10:36:21Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 ++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 224 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dede60d27b..3ac1272d7e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -793,6 +803,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -838,7 +851,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 284796c49b..cbdac1a19a 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1ecbafbee1..4ffd224a71 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1755,4 +1755,121 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548870","messageId":"048c002cb4bc4d5fe107406a6da0479807db4b49.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:12Z","receivedAt":"2026-07-24T10:36:23Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 3ac1272d7e..09631f93f7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -895,7 +901,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"548871","messageId":"bb93303df348a5c6db3a7eafab2c1ea2c7fd2e40.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:13Z","receivedAt":"2026-07-24T10:36:24Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 09631f93f7..504117d1c3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"548872","messageId":"e70e64c866676f4223059e5422a3fb38115f4763.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:14Z","receivedAt":"2026-07-24T10:36:26Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 504117d1c3..1ef8362c12 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"548873","messageId":"598e68751e75931c7c1bd7f062e2957ecf8375be.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:15Z","receivedAt":"2026-07-24T10:36:28Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <branch>)... [<pattern>...]\n\ndeletes local branches matching the optional patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nThe option can be repeated to widen the upstream match. Keeping the\ncandidate patterns as positional arguments lets users bound the set of\nlocal branches that may be deleted independently of the upstream\nselection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it by name to the remote configured by\n    branch.<name>.remote would update its upstream, as determined by\n    mapping the branch ref through that remote's fetch refspec. For\n    example, a local \"main\" that tracks \"origin/main\" is kept even when\n    remote.pushDefault names a fork. Right after a pull it merely looks\n    fully merged.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that a surviving branch depends on through a chain of local\nupstreams is also kept, so no branch is deleted out from under stacked\nwork. Collect this transitive set without changing the candidate set\nduring ref iteration: walk upstream chains from surviving branches,\nvisit each branch at most once, and remove the collected bases only\nafter the iteration completes. This makes the result independent of\nref iteration order without repeated full scans.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  30 +++++\n builtin/branch.c              | 158 +++++++++++++++++++++++++-\n t/t3200-branch.sh             | 204 ++++++++++++++++++++++++++++++++++\n 3 files changed, 390 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..2a96cd7253 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<branch>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. The option can be repeated to widen the upstream match.\n+\tOptional _<pattern>_ arguments limit which local branches are\n+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree, or\n+* pushing it by name to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks \"fully merged\" right\n+  after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that a surviving branch depends on through a chain of local\n+upstreams is kept, so a branch is never deleted out from under stacked\n+work.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ef8362c12..e7677dcefa 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n \tNULL\n };\n \n@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+\tstruct strset *visited_branch_names;\n+};\n+\n+static int collect_stacked_branch_bases(const struct reference *ref,\n+\t\t\t\t\tvoid *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n+\t\tstruct branch *branch = branch_get(branch_name);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tbreak;\n+\n+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\t\tbranch_name = upstream_branch_name;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names)\n+{\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strset visited_branch_names = STRSET_INIT;\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = &protected_branch_names,\n+\t\t.visited_branch_names = &visited_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n+\n+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+\n+\tstrset_clear(&visited_branch_names);\n+\tstrset_clear(&protected_branch_names);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (remote)\n+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint ret = 0;\n+\n+\tfor (size_t i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (size_t i = 0; i < (size_t)candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -763,6 +907,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -816,6 +961,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -863,7 +1011,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -873,7 +1022,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -915,6 +1064,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1083,6 +1236,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4ffd224a71..268203089b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" origin/next --track &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next --track &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete main --track &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* \\\n+\t\t\t--delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature --track &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit branch lower origin/next --track &&\n+\t\tgit branch mid lower --track &&\n+\t\tgit checkout -b tip mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tlower\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch c-lower origin/next --track &&\n+\t\tgit branch b-mid c-lower --track &&\n+\t\tgit checkout -b a-tip b-mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548874","messageId":"ac63689d8aad83eda01c06ddf6c7a8c3fd3df7bc.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:16Z","receivedAt":"2026-07-24T10:36:29Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 60 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2a96cd7253..2b206e8689 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,11 +216,12 @@ A branch is not deleted when:\n +\n --\n * its configured upstream ref no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * pushing it by name to the remote configured by\n   `branch.<name>.remote` would update its upstream, so it cannot be\n   distinguished from a branch that just looks \"fully merged\" right\n-  after a pull.\n+  after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex e7677dcefa..b8aa5054cb 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tint ret = 0;\n@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&deletable_branch_names);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 268203089b..7111306150 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548875","messageId":"73c3281b6fb263b0bd68b6ac5a9d68d2c447ade4.1784889377.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v21 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T10:36:17Z","receivedAt":"2026-07-24T10:36:31Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 21 ++++++++++++++++---\n t/t3200-branch.sh             | 38 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 62 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2b206e8689..51dda15114 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <branch>)... [<pattern>...]\n+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local\n upstreams is kept, so a branch is never deleted out from under stacked\n work.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex b8aa5054cb..a09694bc5c 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -340,13 +341,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -922,6 +930,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -978,6 +987,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1080,7 +1094,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 7111306150..eb1c57a5ca 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other other/main --track &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic feature --track &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n \t\tgit checkout -b tip mid --track &&\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower >actual 2>&1 &&\n \t\ttest_must_be_empty actual &&\n@@ -2112,4 +2143,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"548896","messageId":"xmqqy0f08kbw.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"Re: [PATCH v21 0/7] branch: delete-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-24T15:43:15Z","receivedAt":"2026-07-24T15:43:18Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Delete branches that have already been merged on upstream.\n>\n> Changes in v21:\n>\n>  * Change loop variables to prevent CI timeout.\n\nI see no other changes, which matches expectations at this point.\n\nIs this topic pretty much done by now, or are there any outstanding\nissues other than this workaround for the performance issue with\n'strvec.cocci' in Coccinelle?\n\nThanks for the prompt update.\n"},{"id":"548908","messageId":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v21.git.git.1784889377.gitgitgadget@gmail.com","subject":"[PATCH v22 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:28Z","receivedAt":"2026-07-24T19:29:38Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v22:\n\n * Loop variable int when appropriate to avoid typecast.\n\nChanges in v21:\n\n * Change loop variables to prevent CI timeout.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  49 +++-\n builtin/branch.c                 | 275 ++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 393 +++++++++++++++++++++++++++++++\n 6 files changed, 773 insertions(+), 31 deletions(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v22\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v22\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v21:\n\n 1:  90aa528bef = 1:  90aa528bef branch: add --forked filter for --list mode\n 2:  048c002cb4 = 2:  048c002cb4 branch: convert delete_branches() to a flags argument\n 3:  bb93303df3 = 3:  bb93303df3 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  e70e64c866 = 4:  e70e64c866 branch: prepare delete_branches for a bulk caller\n 5:  598e68751e ! 5:  7dbfc6e243 branch: add --delete-merged <branch>\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tfilter.name_patterns = argv;\n      +\tfilter_refs(&candidates, &filter, filter.kind);\n      +\n     -+\tfor (size_t i = 0; i < (size_t)candidates.nr; i++) {\n     ++\tfor (int i = 0; i < candidates.nr; i++) {\n      +\t\tconst char *branch_refname = candidates.items[i]->refname;\n      +\t\tconst char *branch_name;\n      +\t\tstruct branch *branch;\n 6:  ac63689d8a = 6:  6f27770b9f branch: add branch.<name>.deleteMerged opt-out\n 7:  73c3281b6f = 7:  4a42b6f5f9 branch: add --dry-run for --delete-merged\n\n-- \ngitgitgadget\n"},{"id":"548909","messageId":"90aa528bef242851ca95794f06a9e9311b83a05d.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:29Z","receivedAt":"2026-07-24T19:29:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 ++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 224 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dede60d27b..3ac1272d7e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -793,6 +803,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -838,7 +851,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 284796c49b..cbdac1a19a 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\tint i;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1ecbafbee1..4ffd224a71 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1755,4 +1755,121 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548910","messageId":"048c002cb4bc4d5fe107406a6da0479807db4b49.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:30Z","receivedAt":"2026-07-24T19:29:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 3ac1272d7e..09631f93f7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -895,7 +901,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"548911","messageId":"bb93303df348a5c6db3a7eafab2c1ea2c7fd2e40.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:31Z","receivedAt":"2026-07-24T19:29:42Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 09631f93f7..504117d1c3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"548912","messageId":"e70e64c866676f4223059e5422a3fb38115f4763.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:32Z","receivedAt":"2026-07-24T19:29:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 504117d1c3..1ef8362c12 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"548914","messageId":"7dbfc6e243a79256a1397da313af48f009d07317.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:33Z","receivedAt":"2026-07-24T19:29:45Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <branch>)... [<pattern>...]\n\ndeletes local branches matching the optional patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nThe option can be repeated to widen the upstream match. Keeping the\ncandidate patterns as positional arguments lets users bound the set of\nlocal branches that may be deleted independently of the upstream\nselection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it by name to the remote configured by\n    branch.<name>.remote would update its upstream, as determined by\n    mapping the branch ref through that remote's fetch refspec. For\n    example, a local \"main\" that tracks \"origin/main\" is kept even when\n    remote.pushDefault names a fork. Right after a pull it merely looks\n    fully merged.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that a surviving branch depends on through a chain of local\nupstreams is also kept, so no branch is deleted out from under stacked\nwork. Collect this transitive set without changing the candidate set\nduring ref iteration: walk upstream chains from surviving branches,\nvisit each branch at most once, and remove the collected bases only\nafter the iteration completes. This makes the result independent of\nref iteration order without repeated full scans.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  30 +++++\n builtin/branch.c              | 158 +++++++++++++++++++++++++-\n t/t3200-branch.sh             | 204 ++++++++++++++++++++++++++++++++++\n 3 files changed, 390 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..2a96cd7253 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<branch>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. The option can be repeated to widen the upstream match.\n+\tOptional _<pattern>_ arguments limit which local branches are\n+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree, or\n+* pushing it by name to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks \"fully merged\" right\n+  after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that a surviving branch depends on through a chain of local\n+upstreams is kept, so a branch is never deleted out from under stacked\n+work.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ef8362c12..78b694034f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n \tNULL\n };\n \n@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+\tstruct strset *visited_branch_names;\n+};\n+\n+static int collect_stacked_branch_bases(const struct reference *ref,\n+\t\t\t\t\tvoid *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n+\t\tstruct branch *branch = branch_get(branch_name);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tbreak;\n+\n+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\t\tbranch_name = upstream_branch_name;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names)\n+{\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strset visited_branch_names = STRSET_INIT;\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = &protected_branch_names,\n+\t\t.visited_branch_names = &visited_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n+\n+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+\n+\tstrset_clear(&visited_branch_names);\n+\tstrset_clear(&protected_branch_names);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (remote)\n+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint ret = 0;\n+\n+\tfor (size_t i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (int i = 0; i < candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -763,6 +907,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -816,6 +961,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -863,7 +1011,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -873,7 +1022,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -915,6 +1064,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1083,6 +1236,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4ffd224a71..268203089b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" origin/next --track &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next --track &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete main --track &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* \\\n+\t\t\t--delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature --track &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit branch lower origin/next --track &&\n+\t\tgit branch mid lower --track &&\n+\t\tgit checkout -b tip mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tlower\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch c-lower origin/next --track &&\n+\t\tgit branch b-mid c-lower --track &&\n+\t\tgit checkout -b a-tip b-mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548913","messageId":"6f27770b9f12562bf3412d1d5748ccbad06fa9a6.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:34Z","receivedAt":"2026-07-24T19:29:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 60 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2a96cd7253..2b206e8689 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,11 +216,12 @@ A branch is not deleted when:\n +\n --\n * its configured upstream ref no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * pushing it by name to the remote configured by\n   `branch.<name>.remote` would update its upstream, so it cannot be\n   distinguished from a branch that just looks \"fully merged\" right\n-  after a pull.\n+  after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 78b694034f..dc1d74077b 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tint ret = 0;\n@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&deletable_branch_names);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 268203089b..7111306150 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548915","messageId":"4a42b6f5f9385999e45e7e912ca78a4cfea2b2a9.1784921375.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v22 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-24T19:29:35Z","receivedAt":"2026-07-24T19:29:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 21 ++++++++++++++++---\n t/t3200-branch.sh             | 38 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 62 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2b206e8689..51dda15114 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <branch>)... [<pattern>...]\n+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local\n upstreams is kept, so a branch is never deleted out from under stacked\n work.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dc1d74077b..e0e91743ca 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -340,13 +341,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -922,6 +930,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -978,6 +987,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1080,7 +1094,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 7111306150..eb1c57a5ca 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other other/main --track &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic feature --track &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n \t\tgit checkout -b tip mid --track &&\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower >actual 2>&1 &&\n \t\ttest_must_be_empty actual &&\n@@ -2112,4 +2143,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"548935","messageId":"xmqq33x73dqo.fsf@gitster.g","threadId":"65574","inReplyTo":"90aa528bef242851ca95794f06a9e9311b83a05d.1784921375.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v22 1/7] branch: add --forked filter for --list mode","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-25T04:16:47Z","receivedAt":"2026-07-25T04:16:53Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/ref-filter.c b/ref-filter.c\n> index 284796c49b..cbdac1a19a 100644\n> --- a/ref-filter.c\n> +++ b/ref-filter.c\n> @@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n>  \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n>  }\n>  \n> +static const char *short_upstream_name(const char *full_ref)\n> +{\n> +\tconst char *short_name = full_ref;\n> +\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n> +\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n> +\treturn short_name;\n> +}\n\nIt is a bit tricky to read the above, which is equivalent to this\n\n\tif (!skip_prefix(short_name, \"refs/heads/\", &short_name))\n\t\tskip_prefix(short_name, \"refs/remotes/\", &short_name);\n\treturn short_name;\n\nwhich is written in a more dumb way.\n\nUnless we are going to add more \"if the string does not begin with\nthis prefix, try this other one\" to the cascade, the dumb \"try\nstripping local branch prefix, if not, try the remote-tracking\nbranch prefix\" way might be easier to understand.\n\n> +/*\n> + * Match the configured upstream of a branch against the registered\n> + * --forked patterns. Exact patterns are compared against the full\n> + * upstream refname so they are unambiguous; glob patterns are matched\n> + * against the abbreviated upstream so that a glob such as origin/...\n> + * works as typed.\n> + */\n> +static int filter_forked_match(struct ref_filter *filter, const char *refname)\n> +{\n> +\tconst char *short_name;\n> +\tstruct branch *branch;\n> +\tconst char *upstream;\n> +\tint i;\n> +\n> +\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n> +\t\treturn 0;\n> +\tbranch = branch_get(short_name);\n> +\tif (!branch)\n> +\t\treturn 0;\n> +\tupstream = branch_get_upstream(branch, NULL);\n> +\tif (!upstream)\n> +\t\treturn 0;\n> +\n> +\tfor (i = 0; i < filter->forked.nr; i++) {\n\nThe 'filter->forked' member is added by this patch and of type\n'struct strvec', filter->forked.nr is of type 'size_t'.  And 'i',\nwhich is of type 'int', is compared with it.\n\n> diff --git a/ref-filter.h b/ref-filter.h\n> index 120221b47f..9361296e2a 100644\n> --- a/ref-filter.h\n> +++ b/ref-filter.h\n> @@ -67,6 +67,7 @@ struct ref_filter {\n>  \tconst char **name_patterns;\n>  \tconst char *start_after;\n>  \tstruct strvec exclude;\n> +\tstruct strvec forked;\n>  \tstruct oid_array points_at;\n>  \tstruct commit_list *with_commit;\n>  \tstruct commit_list *no_commit;\n"},{"id":"548946","messageId":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v22.git.git.1784921375.gitgitgadget@gmail.com","subject":"[PATCH v23 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:09Z","receivedAt":"2026-07-25T11:32:21Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v23:\n\n * Use a loop-local size_t iterator for --forked patterns.\n * Simplify upstream-prefix stripping into an explicit fallback.\n\nChanges in v22:\n\n * Loop variable int when appropriate to avoid typecast.\n\nChanges in v21:\n\n * Change loop variables to prevent CI timeout.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <branch>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  49 +++-\n builtin/branch.c                 | 275 ++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 393 +++++++++++++++++++++++++++++++\n 6 files changed, 773 insertions(+), 31 deletions(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v23\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v23\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v22:\n\n 1:  90aa528bef ! 1:  0c4fe549cb branch: add --forked filter for --list mode\n     @@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c\n      +static const char *short_upstream_name(const char *full_ref)\n      +{\n      +\tconst char *short_name = full_ref;\n     -+\t(void)(skip_prefix(short_name, \"refs/heads/\", &short_name) ||\n     -+\t       skip_prefix(short_name, \"refs/remotes/\", &short_name));\n     ++\n     ++\tif (!skip_prefix(short_name, \"refs/heads/\", &short_name))\n     ++\t\tskip_prefix(short_name, \"refs/remotes/\", &short_name);\n      +\treturn short_name;\n      +}\n      +\n     @@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c\n      +\tconst char *short_name;\n      +\tstruct branch *branch;\n      +\tconst char *upstream;\n     -+\tint i;\n      +\n      +\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n      +\t\treturn 0;\n     @@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const c\n      +\tif (!upstream)\n      +\t\treturn 0;\n      +\n     -+\tfor (i = 0; i < filter->forked.nr; i++) {\n     ++\tfor (size_t i = 0; i < filter->forked.nr; i++) {\n      +\t\tconst char *pattern = filter->forked.v[i];\n      +\t\tif (has_glob_specials(pattern)) {\n      +\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n 2:  048c002cb4 = 2:  2d20015ba9 branch: convert delete_branches() to a flags argument\n 3:  bb93303df3 = 3:  34b37aeb43 branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  e70e64c866 = 4:  606ce4082c branch: prepare delete_branches for a bulk caller\n 5:  7dbfc6e243 = 5:  5fd74f0050 branch: add --delete-merged <branch>\n 6:  6f27770b9f = 6:  6494be7c75 branch: add branch.<name>.deleteMerged opt-out\n 7:  4a42b6f5f9 = 7:  7102c931e2 branch: add --dry-run for --delete-merged\n\n-- \ngitgitgadget\n"},{"id":"548947","messageId":"0c4fe549cbab2d2fb69ca66b24292ab2f3d5d762.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:10Z","receivedAt":"2026-07-25T11:32:23Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 ++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 117 ++++++++++++++++++++++++++++++++++\n 5 files changed, 224 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dede60d27b..3ac1272d7e 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -793,6 +803,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -838,7 +851,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 284796c49b..df6fb143d7 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\n+\tif (!skip_prefix(short_name, \"refs/heads/\", &short_name))\n+\t\tskip_prefix(short_name, \"refs/remotes/\", &short_name);\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (size_t i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1ecbafbee1..4ffd224a71 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1755,4 +1755,121 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548948","messageId":"2d20015ba91d994b61754fd79da558133a8aeddc.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:11Z","receivedAt":"2026-07-25T11:32:26Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 3ac1272d7e..09631f93f7 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n \t\tfree(name);\n@@ -279,7 +285,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -291,7 +297,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -306,16 +312,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -330,7 +336,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -895,7 +901,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"548949","messageId":"34b37aeb435654fc0c129e32db4a1fcfd8fc8dec.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:12Z","receivedAt":"2026-07-25T11:32:28Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 09631f93f7..504117d1c3 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -315,7 +319,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"548950","messageId":"606ce4082cdc1c3a5a33fa4615374b5f00ebfdba.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:13Z","receivedAt":"2026-07-25T11:32:31Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 504117d1c3..1ef8362c12 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"548951","messageId":"5fd74f0050e5af1f2ab03ddae56dc96385e6a237.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:14Z","receivedAt":"2026-07-25T11:32:35Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <branch>)... [<pattern>...]\n\ndeletes local branches matching the optional patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nThe option can be repeated to widen the upstream match. Keeping the\ncandidate patterns as positional arguments lets users bound the set of\nlocal branches that may be deleted independently of the upstream\nselection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it by name to the remote configured by\n    branch.<name>.remote would update its upstream, as determined by\n    mapping the branch ref through that remote's fetch refspec. For\n    example, a local \"main\" that tracks \"origin/main\" is kept even when\n    remote.pushDefault names a fork. Right after a pull it merely looks\n    fully merged.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nA branch that a surviving branch depends on through a chain of local\nupstreams is also kept, so no branch is deleted out from under stacked\nwork. Collect this transitive set without changing the candidate set\nduring ref iteration: walk upstream chains from surviving branches,\nvisit each branch at most once, and remove the collected bases only\nafter the iteration completes. This makes the result independent of\nref iteration order without repeated full scans.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  30 +++++\n builtin/branch.c              | 158 +++++++++++++++++++++++++-\n t/t3200-branch.sh             | 204 ++++++++++++++++++++++++++++++++++\n 3 files changed, 390 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..2a96cd7253 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <branch>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<branch>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. The option can be repeated to widen the upstream match.\n+\tOptional _<pattern>_ arguments limit which local branches are\n+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree, or\n+* pushing it by name to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks \"fully merged\" right\n+  after a pull.\n+--\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n++\n+A branch that a surviving branch depends on through a chain of local\n+upstreams is kept, so a branch is never deleted out from under stacked\n+work.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ef8362c12..78b694034f 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n \tNULL\n };\n \n@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+\tstruct strset *visited_branch_names;\n+};\n+\n+static int collect_stacked_branch_bases(const struct reference *ref,\n+\t\t\t\t\tvoid *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n+\t\tstruct branch *branch = branch_get(branch_name);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tbreak;\n+\n+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\t\tbranch_name = upstream_branch_name;\n+\t}\n+\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names)\n+{\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strset visited_branch_names = STRSET_INIT;\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = &protected_branch_names,\n+\t\t.visited_branch_names = &visited_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n+\n+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+\n+\tstrset_clear(&visited_branch_names);\n+\tstrset_clear(&protected_branch_names);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (remote)\n+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint ret = 0;\n+\n+\tfor (size_t i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (int i = 0; i < candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -763,6 +907,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -816,6 +961,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -863,7 +1011,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -873,7 +1022,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -915,6 +1064,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1083,6 +1236,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 4ffd224a71..268203089b 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" origin/next --track &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged origin/next --track &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete main --track &&\n+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n+\t\tgit update-ref -d refs/remotes/origin/topic &&\n+\t\tgit checkout -b tracks-other other/main --track &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* \\\n+\t\t\t--delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic feature --track &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit branch lower origin/next --track &&\n+\t\tgit branch mid lower --track &&\n+\t\tgit checkout -b tip mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tlower\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch c-lower origin/next --track &&\n+\t\tgit branch b-mid c-lower --track &&\n+\t\tgit checkout -b a-tip b-mid --track &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tc-lower\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548952","messageId":"6494be7c75d1298912d2149f149a72bbbad97cf4.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:15Z","receivedAt":"2026-07-25T11:32:38Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  5 +++--\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 60 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2a96cd7253..2b206e8689 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -216,11 +216,12 @@ A branch is not deleted when:\n +\n --\n * its configured upstream ref no longer exists,\n-* it is checked out in any worktree, or\n+* it is checked out in any worktree,\n * pushing it by name to the remote configured by\n   `branch.<name>.remote` would update its upstream, so it cannot be\n   distinguished from a branch that just looks \"fully merged\" right\n-  after a pull.\n+  after a pull, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n A branch whose work has not yet been merged into its upstream is\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 78b694034f..dc1d74077b 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -786,6 +786,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct ref_array candidates = { 0 };\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tint ret = 0;\n@@ -804,6 +805,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -821,6 +823,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -836,6 +849,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&deletable_branch_names);\n \tref_array_clear(&candidates);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 268203089b..7111306150 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2076,4 +2076,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"548953","messageId":"7102c931e2adeb5d95f3eaf54f9b3f6d6897d78e.1784979136.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v23 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-25T11:32:16Z","receivedAt":"2026-07-25T11:32:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 21 ++++++++++++++++---\n t/t3200-branch.sh             | 38 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 62 insertions(+), 5 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 2b206e8689..51dda15114 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <branch>)... [<pattern>...]\n+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n \n DESCRIPTION\n -----------\n@@ -232,6 +232,12 @@ A branch that a surviving branch depends on through a chain of local\n upstreams is kept, so a branch is never deleted out from under stacked\n work.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex dc1d74077b..e0e91743ca 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -199,6 +199,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -340,13 +341,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -922,6 +930,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -978,6 +987,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1040,6 +1051,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1080,7 +1094,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 7111306150..eb1c57a5ca 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other other/main --track &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -1986,9 +1999,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic feature --track &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2016,6 +2032,21 @@ test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n \t\tgit checkout -b tip mid --track &&\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower >actual 2>&1 &&\n \t\ttest_must_be_empty actual &&\n@@ -2112,4 +2143,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"548964","messageId":"xmqqy0ez14s9.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"Re: [PATCH v23 0/7] branch: delete-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-25T15:13:10Z","receivedAt":"2026-07-25T15:13:12Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Delete branches that have already been merged on upstream.\n>\n> Changes in v23:\n>\n>  * Use a loop-local size_t iterator for --forked patterns.\n>  * Simplify upstream-prefix stripping into an explicit fallback.\n\nThat was quick.  I have no more comments to add.\n\nThanks.\n"},{"id":"549210","messageId":"1f282ad4-9937-4c95-89d4-70f7a1c883a8@gmail.com","threadId":"65574","inReplyTo":"5fd74f0050e5af1f2ab03ddae56dc96385e6a237.1784979136.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v23 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-29T15:10:12Z","receivedAt":"2026-07-29T15:10:20Z","isPatch":true,"body":"Hi Harald\n\nSorry it has taken so long for we to look at this again - I'm not sure \nwhere last week went.\n\nOn 25/07/2026 12:32, Harald Nordgren via GitGitGadget wrote:\n> From: Harald Nordgren <haraldnordgren@gmail.com>\n> \n>      git branch (--delete-merged <branch>)... [<pattern>...]\n> \n> deletes local branches matching the optional patterns when their\n> configured upstream matches one of the --delete-merged arguments and\n> their tip is reachable from that upstream. The work has already landed\n> on the upstream they track, so the local copy is no longer needed.\n> \n> The option can be repeated to widen the upstream match. Keeping the\n> candidate patterns as positional arguments lets users bound the set of\n> local branches that may be deleted independently of the upstream\n> selection.\n> \n> A branch is not deleted when:\n> \n>    * it is checked out in any worktree\n>    * its configured upstream ref no longer exists, since a missing\n>      upstream is not by itself a sign of integration\n>    * pushing it by name to the remote configured by\n>      branch.<name>.remote would update its upstream, as determined by\n>      mapping the branch ref through that remote's fetch refspec. For\n>      example, a local \"main\" that tracks \"origin/main\" is kept even when\n>      remote.pushDefault names a fork. Right after a pull it merely looks\n>      fully merged.\n> \n> A branch whose work is not yet merged into its upstream is silently\n> skipped, so one unmerged topic does not abort the whole sweep.\n> \n> A branch that a surviving branch depends on through a chain of local\n> upstreams is also kept, so no branch is deleted out from under stacked\n> work. \n\nShouldn't this be part of the list above. So we no-longer delete any \nbranch in a chain of stacked branches when one of them is unmerged? \nPreviously we only kept the upstream of the unmerged branch and deleted \nthe rest.\n\nCollect this transitive set without changing the candidate set\n> during ref iteration: walk upstream chains from surviving branches,\n> visit each branch at most once, and remove the collected bases only\n> after the iteration completes. This makes the result independent of\n> ref iteration order without repeated full scans.\n> \n> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n> ---\n>   Documentation/git-branch.adoc |  30 +++++\n>   builtin/branch.c              | 158 +++++++++++++++++++++++++-\n>   t/t3200-branch.sh             | 204 ++++++++++++++++++++++++++++++++++\n>   3 files changed, 390 insertions(+), 2 deletions(-)\n> \n> diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\n> index b0d66a6deb..2a96cd7253 100644\n> --- a/Documentation/git-branch.adoc\n> +++ b/Documentation/git-branch.adoc\n> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n>   git branch (-c|-C) [<old-branch>] <new-branch>\n>   git branch (-d|-D) [-r] <branch-name>...\n>   git branch --edit-description [<branch-name>]\n> +git branch (--delete-merged <branch>)... [<pattern>...]\n>   \n>   DESCRIPTION\n>   -----------\n> @@ -201,6 +202,35 @@ This option is only applicable in non-verbose mode.\n>   \tPrint the name of the current branch. In detached `HEAD` state,\n>   \tnothing is printed.\n>   \n> +`--delete-merged <branch>`::\n> +\tDelete local branches whose configured upstream matches\n> +\t_<branch>_, but only when their tip is reachable from that\n> +\tupstream. In other words, the work on the branch has already\n> +\tlanded on the upstream it tracks, so the local copy is no longer\n> +\tneeded. The option can be repeated to widen the upstream match.\n> +\tOptional _<pattern>_ arguments limit which local branches are\n> +\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n> +\t'topic-*'`.\n> ++\n> +A branch is not deleted when:\n> ++\n> +--\n> +* its configured upstream ref no longer exists,\n> +* it is checked out in any worktree, or\n> +* pushing it by name to the remote configured by\n> +  `branch.<name>.remote` would update its upstream, so it cannot be\n> +  distinguished from a branch that just looks \"fully merged\" right\n> +  after a pull.\n\n  * it is the upstream of an unmerged branch\n\n> +--\n> ++\n> +A branch whose work has not yet been merged into its upstream is\n> +silently skipped. Delete it with `git branch -D` if you want to\n> +remove it anyway.\n> ++\n> +A branch that a surviving branch depends on through a chain of local\n> +upstreams is kept, so a branch is never deleted out from under stacked\n> +work.\n> +\n>   `-v`::\n>   `-vv`::\n>   `--verbose`::\n> diff --git a/builtin/branch.c b/builtin/branch.c\n> index 1ef8362c12..78b694034f 100644\n> --- a/builtin/branch.c\n> +++ b/builtin/branch.c\n> @@ -21,6 +21,7 @@\n>   #include \"branch.h\"\n>   #include \"path.h\"\n>   #include \"string-list.h\"\n> +#include \"strmap.h\"\n>   #include \"column.h\"\n>   #include \"utf8.h\"\n>   #include \"ref-filter.h\"\n> @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n>   \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>   \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n> +\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n\nI don't quite follow this - why the \"()\" and doesn't --delete-merged \ntake a pattern?\n\n>   \tNULL\n>   };\n>   \n> @@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n>   \treturn 0;\n>   }\n>   \n> +struct stacked_branch_data {\n> +\tstruct strset *deletable_branch_names;\n> +\tstruct strset *protected_branch_names;\n> +\tstruct strset *visited_branch_names;\n> +};\n> +\n> +static int collect_stacked_branch_bases(const struct reference *ref,\n> +\t\t\t\t\tvoid *cb_data)\n> +{\n> +\tstruct stacked_branch_data *data = cb_data;\n> +\tconst char *branch_name;\n> +\n> +\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n> +\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n> +\tif (strset_contains(data->deletable_branch_names, branch_name))\n> +\t\treturn 0;\n> +\n> +\twhile (strset_add(data->visited_branch_names, branch_name)) {\n> +\t\tstruct branch *branch = branch_get(branch_name);\n> +\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n> +\t\tconst char *upstream_branch_name;\n> +\n> +\t\tif (!upstream_refname ||\n> +\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n> +\t\t\t\t &upstream_branch_name) ||\n> +\t\t    !strset_contains(data->deletable_branch_names,\n> +\t\t\t\t    upstream_branch_name))\n> +\t\t\tbreak;\n> +\n> +\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n> +\t\tbranch_name = upstream_branch_name;\n> +\t}\n\nThis looks correct, it is a shame we have to build \n\"visited_branch_names\" but the code is clear.\n\n> +\treturn 0;\n> +}\n> +\n> +static void protect_stacked_branch_bases(struct ref_store *refs,\n> +\t\t\t\t\t struct strset *deletable_branch_names)\n> +{\n> +\tstruct strset protected_branch_names = STRSET_INIT;\n> +\tstruct strset visited_branch_names = STRSET_INIT;\n> +\tstruct stacked_branch_data data = {\n> +\t\t.deletable_branch_names = deletable_branch_names,\n> +\t\t.protected_branch_names = &protected_branch_names,\n> +\t\t.visited_branch_names = &visited_branch_names,\n> +\t};\n> +\tstruct refs_for_each_ref_options opts = {\n> +\t\t.prefix = \"refs/heads/\",\n> +\t};\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *entry;\n> +\n> +\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n> +\n> +\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n> +\t\tstrset_remove(deletable_branch_names, entry->key);\n\nWe remove the protected branches from deleteable - good\n\n> +\n> +\tstrset_clear(&visited_branch_names);\n> +\tstrset_clear(&protected_branch_names);\n> +}\n> +\n> +static int branch_pushes_to_upstream(struct branch *branch,\n> +\t\t\t\t     const char *upstream)\n> +{\n> +\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n> +\tchar *tracking = NULL;\n> +\tint ret = 0;\n> +\n> +\tif (remote)\n> +\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n\nThis tells us which remote tracking ref corresponds to the branch\n\n> +\tif (tracking && !strcmp(tracking, upstream))\n> +\t\tret = 1;\n\nHere we check that it does not match the upstream branch. That ignores \nthe push refspect though so does not tell us whether pushing the branch \nto the upstream remote would update the upstream branch on that remote.\n\nWe need to apply the push refspec to the local branch, apply the fetch \nrefspec in reverse to the result and then compare that to the upstream \nbranch.\n\n> +\n> +\tfree(tracking);\n> +\treturn ret;\n> +}\n> +\n> +static int delete_merged_branches(const struct strvec *upstreams,\n> +\t\t\t\t const char **argv, unsigned int flags)\n> +{\n> +\tstruct ref_store *refs = get_main_ref_store(the_repository);\n> +\tstruct ref_filter filter = REF_FILTER_INIT;\n> +\tstruct ref_array candidates = { 0 };\n> +\tstruct strset deletable_branch_names = STRSET_INIT;\n> +\tstruct strvec branches_to_delete = STRVEC_INIT;\n> +\tstruct hashmap_iter iter;\n> +\tstruct strmap_entry *entry;\n> +\tint ret = 0;\n> +\n> +\tfor (size_t i = 0; i < upstreams->nr; i++)\n> +\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n> +\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n> +\t\t\t    upstreams->v[i]);\n> +\n> +\tfilter.kind = FILTER_REFS_BRANCHES;\n> +\tfilter.name_patterns = argv;\n> +\tfilter_refs(&candidates, &filter, filter.kind);\n> +\n> +\tfor (int i = 0; i < candidates.nr; i++) {\n> +\t\tconst char *branch_refname = candidates.items[i]->refname;\n> +\t\tconst char *branch_name;\n> +\t\tstruct branch *branch;\n> +\t\tconst char *upstream_refname;\n> +\n> +\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n> +\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n> +\t\tif (branch_checked_out(branch_refname))\n> +\t\t\tcontinue;\n> +\n> +\t\tbranch = branch_get(branch_name);\n> +\t\tupstream_refname = branch_get_upstream(branch, NULL);\n> +\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n> +\t\t\tcontinue;\n> +\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n> +\t\t\tcontinue;\n> +\t\tif (check_branch_commit(branch_name, branch_name,\n> +\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n> +\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n> +\t\t\tcontinue;\n> +\n> +\t\tstrset_add(&deletable_branch_names, branch_name);\n> +\t}\n\nWe build the set of branches to delete and then\n\n> +\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n\nremove the ones that are upstreams of branches that are not in the set \nand then\n\n> +\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n> +\t\tstrvec_push(&branches_to_delete, entry->key);\n\nbuild a list of branches to delete - good.\n\n> +\tif (branches_to_delete.nr)\n> +\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n> +\t\t\t\t      FILTER_REFS_BRANCHES,\n> +\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n> +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n> +\t\t\t\t      flags);\n> +\n> +\tstrvec_clear(&branches_to_delete);\n> +\tstrset_clear(&deletable_branch_names);\n> +\tref_array_clear(&candidates);\n> +\tref_filter_clear(&filter);\n> +\treturn ret;\n> +}\n\n> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\n> index 4ffd224a71..268203089b 100755\n> --- a/t/t3200-branch.sh\n> +++ b/t/t3200-branch.sh\n> @@ -1872,4 +1872,208 @@ test_expect_success '--forked requires a value' '\n>   \ttest_grep \"requires a value\" err\n>   '\n>   \n> +test_expect_success '--delete-merged: setup' '\n> +\tgit init -b main upstream &&\n> +\t(\n> +\t\tcd upstream &&\n> +\t\ttest_commit base &&\n> +\t\tgit checkout -b next &&\n> +\t\ttest_commit next-work &&\n> +\t\tgit checkout main\n> +\t) &&\n> +\tgit init -b main other &&\n> +\ttest_commit -C other other-base &&\n> +\tgit init -b main fork\n> +'\n> +\n> +setup_repo_for_delete_merged () {\n> +\trm -rf repo &&\n> +\tgit clone upstream repo &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit remote add fork ../fork &&\n> +\t\tgit remote add other ../other &&\n> +\t\tgit config push.default current &&\n> +\t\tgit fetch other\n> +\t)\n> +}\n> +\n> +create_merged_branch () {\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b \"$1\" origin/next --track &&\n> +\t\tgit commit --allow-empty -m \"$1 work\" &&\n> +\t\tgit push origin \"$1:next\"\n> +\t)\n> +}\n> +\n> +check_branches () {\n> +\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n> +\tcat >expect &&\n> +\ttest_cmp expect actual\n> +}\n\nThis makes checking the remaining branches in the tests really nice> +\n> +test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout --detach &&\n> +\n> +\t\tgit branch --delete-merged */* &&\n\nUnless I've missed something main does not have an upstream branch set, \nso we'd never expect it to be deleted, even if a push remote was set.\n\n> +\n> +\t\tcheck_branches <<-\\EOF\n> +\t\tmain\n> +\t\tEOF\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged deletes only selected merged branches' '\n> +\tsetup_repo_for_delete_merged &&\n> +\tcreate_merged_branch also-merged &&\n> +\tcreate_merged_branch merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b unmerged origin/next --track &&\n\nIn the tests we try to avoid mixing options and positional arguments so \nwe use either\n\tgit checkout --track -b unmerged origin/next\nor\n\tgit checkout -b unmerged --track origin/next\n\n> +\t\tgit commit --allow-empty -m \"unmerged work\" &&\n> +\t\tgit checkout -b tracks-other other/main --track &&\n> +\t\tsha=$(git rev-parse --short merged) &&\n> +\n> +\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n> +\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n> +\t\ttest_cmp expect actual &&\n> +\n> +\t\tcheck_branches <<-\\EOF\n> +\t\talso-merged\n\nAs we passed \"merged\" on the command line \"also-merged\" was not deleted \n- good.\n\n> +\t\tmain\n> +\t\ttracks-other\n> +\t\tunmerged\n> +\t\tEOF\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged keeps main despite a different default push remote' '\n> +\tsetup_repo_for_delete_merged &&\n> +\tcreate_merged_branch on-next &&\n> +\tcreate_merged_branch checked-out &&\n> +\tcreate_merged_branch upstream-gone &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit config remote.pushDefault fork &&\n> +\t\tgit checkout -b local-to-delete main --track &&\n> +\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n> +\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n> +\t\tgit update-ref -d refs/remotes/origin/topic &&\n\nAs I think I said last time, this is a very round-about way to have the \nupstream gone. It would be much simpler just to set the config directly.\n\n> +\t\tgit checkout -b tracks-other other/main --track &&\n> +\t\tgit checkout checked-out &&\n> +\n> +\t\tgit branch --delete-merged origin/* \\\n> +\t\t\t--delete-merged main &&\n\nThese lines look very short why the wrapping?\n\n> +\n> +\t\tcheck_branches <<-\\EOF\n> +\t\tchecked-out\n> +\t\tmain\n> +\t\ttracks-other\n> +\t\tupstream-gone\n> +\t\tEOF\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n> +\tsetup_repo_for_delete_merged &&\n> +\tcreate_merged_branch feature &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit checkout -b topic feature --track &&\n\nwe base our branch on feature which is merged into origin/next\n\n> +\t\tgit commit --allow-empty -m \"topic work\" &&\n> +\n> +\t\tgit branch --delete-merged origin/next 2>err &&\n> +\n> +\t\ttest_must_be_empty err &&\n> +\t\tcheck_branches <<-\\EOF &&\n> +\t\tfeature\n> +\t\tmain\n> +\t\ttopic\n> +\t\tEOF\n> +\n> +\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n> +\t\tcat >expect <<-\\EOF &&\n> +\t\tbranch.feature.remote origin\n> +\t\tbranch.feature.merge refs/heads/next\n\nand so we keep feature and it's upstream config (because it's upstream \nbranch still exists) - good.\n\n> +\t\tbranch.topic.remote .\n> +\t\tbranch.topic.merge refs/heads/feature\n> +\t\tEOF\n> +\t\ttest_cmp expect actual\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit config remote.pushDefault fork &&\n> +\t\tgit branch lower origin/next --track &&\n> +\t\tgit branch mid lower --track &&\n> +\t\tgit checkout -b tip mid --track &&\n> +\t\tgit commit --allow-empty -m \"tip work\" &&\n> +\n> +\t\tgit branch --delete-merged origin/next \\\n> +\t\t\t--delete-merged lower >actual 2>&1 &&\n> +\t\ttest_must_be_empty actual &&\n> +\n> +\t\tcheck_branches <<-\\EOF &&\n> +\t\tlower\n\nWhy do we keep \"lower\", rather than clear the upstream config of \"mid\"?\n\n\nI'm not completely convinced by the new behavior wrt stacked branches, \nbut this looks good apart from the branch_pushes_to_upstream() issue.\n\nThanks\n\nPhillip\n\n> +\t\tmain\n> +\t\tmid\n> +\t\ttip\n> +\t\tEOF\n> +\n> +\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n> +\t\tcat >expect <<-\\EOF &&\n> +\t\tbranch.lower.remote origin\n> +\t\tbranch.lower.merge refs/heads/next\n> +\t\tbranch.mid.remote .\n> +\t\tbranch.mid.merge refs/heads/lower\n> +\t\tbranch.tip.remote .\n> +\t\tbranch.tip.merge refs/heads/mid\n> +\t\tEOF\n> +\t\ttest_cmp expect actual\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged result is independent of stacked branch names' '\n> +\tsetup_repo_for_delete_merged &&\n> +\t(\n> +\t\tcd repo &&\n> +\t\tgit branch c-lower origin/next --track &&\n> +\t\tgit branch b-mid c-lower --track &&\n> +\t\tgit checkout -b a-tip b-mid --track &&\n> +\t\tgit commit --allow-empty -m \"tip work\" &&\n> +\n> +\t\tgit branch --delete-merged origin/next \\\n> +\t\t\t--delete-merged \"c-*\" &&\n> +\n> +\t\tcheck_branches <<-\\EOF &&\n> +\t\ta-tip\n> +\t\tb-mid\n> +\t\tc-lower\n> +\t\tmain\n> +\t\tEOF\n> +\n> +\t\tgit branch --delete-merged origin/next \\\n> +\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n> +\t\ttest_must_be_empty actual &&\n> +\n> +\t\tcheck_branches <<-\\EOF\n> +\t\ta-tip\n> +\t\tb-mid\n> +\t\tc-lower\n> +\t\tmain\n> +\t\tEOF\n> +\t)\n> +'\n> +\n> +test_expect_success '--delete-merged requires a value' '\n> +\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n> +\ttest_grep \"requires a value\" err\n> +'\n>   test_done\n\n"},{"id":"549245","messageId":"CAHwyqnV_Uj3anbU9xRBtEUP3M84y9obK6+kx3c1s18NV2ta8eA@mail.gmail.com","threadId":"65574","inReplyTo":"1f282ad4-9937-4c95-89d4-70f7a1c883a8@gmail.com","subject":"Re: [PATCH v23 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-29T23:13:45Z","receivedAt":"2026-07-29T23:14:25Z","isPatch":true,"body":"> Hi Harald\n>\n> Sorry it has taken so long for we to look at this again - I'm not sure\n> where last week went.\n\nNo worries!\n\n> > A branch is not deleted when:\n> >\n> >    * it is checked out in any worktree\n> >    * its configured upstream ref no longer exists, since a missing\n> >      upstream is not by itself a sign of integration\n> >    * pushing it by name to the remote configured by\n> >      branch.<name>.remote would update its upstream, as determined by\n> >      mapping the branch ref through that remote's fetch refspec. For\n> >      example, a local \"main\" that tracks \"origin/main\" is kept even when\n> >      remote.pushDefault names a fork. Right after a pull it merely looks\n> >      fully merged.\n> >\n> > A branch whose work is not yet merged into its upstream is silently\n> > skipped, so one unmerged topic does not abort the whole sweep.\n> >\n> > A branch that a surviving branch depends on through a chain of local\n> > upstreams is also kept, so no branch is deleted out from under stacked\n> > work.\n>\n> Shouldn't this be part of the list above. So we no-longer delete any\n> branch in a chain of stacked branches when one of them is unmerged?\n> Previously we only kept the upstream of the unmerged branch and deleted\n> the rest.\n\nGood point.\n\n> > +* its configured upstream ref no longer exists,\n> > +* it is checked out in any worktree, or\n> > +* pushing it by name to the remote configured by\n> > +  `branch.<name>.remote` would update its upstream, so it cannot be\n> > +  distinguished from a branch that just looks \"fully merged\" right\n> > +  after a pull.\n>\n>   * it is the upstream of an unmerged branch\n\nThanks!\n\n> > +--\n> > ++\n> > +A branch whose work has not yet been merged into its upstream is\n> > +silently skipped. Delete it with `git branch -D` if you want to\n> > +remove it anyway.\n> > ++\n> > +A branch that a surviving branch depends on through a chain of local\n> > +upstreams is kept, so a branch is never deleted out from under stacked\n> > +work.\n> > +\n> >   `-v`::\n> >   `-vv`::\n> >   `--verbose`::\n> > diff --git a/builtin/branch.c b/builtin/branch.c\n> > index 1ef8362c12..78b694034f 100644\n> > --- a/builtin/branch.c\n> > +++ b/builtin/branch.c\n> > @@ -21,6 +21,7 @@\n> >   #include \"branch.h\"\n> >   #include \"path.h\"\n> >   #include \"string-list.h\"\n> > +#include \"strmap.h\"\n> >   #include \"column.h\"\n> >   #include \"utf8.h\"\n> >   #include \"ref-filter.h\"\n> > @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n> >       N_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n> >       N_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n> >       N_(\"git branch [<options>] [-r | -a] [--format]\"),\n> > +     N_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n>\n> I don't quite follow this - why the \"()\" and doesn't --delete-merged\n> take a pattern?\n\nI don't get this one, but would this be better?\n\n```\ngit branch [--dry-run] --delete-merged <pattern>\n   [--delete-merged <pattern>]... [<branch-pattern>...]\n```\n\n> >       NULL\n> >   };\n> >\n> > @@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n> >       return 0;\n> >   }\n> >\n> > +struct stacked_branch_data {\n> > +     struct strset *deletable_branch_names;\n> > +     struct strset *protected_branch_names;\n> > +     struct strset *visited_branch_names;\n> > +};\n> > +\n> > +static int collect_stacked_branch_bases(const struct reference *ref,\n> > +                                     void *cb_data)\n> > +{\n> > +     struct stacked_branch_data *data = cb_data;\n> > +     const char *branch_name;\n> > +\n> > +     if (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n> > +             BUG(\"expected local branch ref, got '%s'\", ref->name);\n> > +     if (strset_contains(data->deletable_branch_names, branch_name))\n> > +             return 0;\n> > +\n> > +     while (strset_add(data->visited_branch_names, branch_name)) {\n> > +             struct branch *branch = branch_get(branch_name);\n> > +             const char *upstream_refname = branch_get_upstream(branch, NULL);\n> > +             const char *upstream_branch_name;\n> > +\n> > +             if (!upstream_refname ||\n> > +                 !skip_prefix(upstream_refname, \"refs/heads/\",\n> > +                              &upstream_branch_name) ||\n> > +                 !strset_contains(data->deletable_branch_names,\n> > +                                 upstream_branch_name))\n> > +                     break;\n> > +\n> > +             strset_add(data->protected_branch_names, upstream_branch_name);\n> > +             branch_name = upstream_branch_name;\n> > +     }\n>\n> This looks correct, it is a shame we have to build\n> \"visited_branch_names\" but the code is clear.\n>\n> > +     return 0;\n> > +}\n> > +\n> > +static void protect_stacked_branch_bases(struct ref_store *refs,\n> > +                                      struct strset *deletable_branch_names)\n> > +{\n> > +     struct strset protected_branch_names = STRSET_INIT;\n> > +     struct strset visited_branch_names = STRSET_INIT;\n> > +     struct stacked_branch_data data = {\n> > +             .deletable_branch_names = deletable_branch_names,\n> > +             .protected_branch_names = &protected_branch_names,\n> > +             .visited_branch_names = &visited_branch_names,\n> > +     };\n> > +     struct refs_for_each_ref_options opts = {\n> > +             .prefix = \"refs/heads/\",\n> > +     };\n> > +     struct hashmap_iter iter;\n> > +     struct strmap_entry *entry;\n> > +\n> > +     refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n> > +\n> > +     strset_for_each_entry(&protected_branch_names, &iter, entry)\n> > +             strset_remove(deletable_branch_names, entry->key);\n>\n> We remove the protected branches from deleteable - good\n>\n> > +\n> > +     strset_clear(&visited_branch_names);\n> > +     strset_clear(&protected_branch_names);\n> > +}\n> > +\n> > +static int branch_pushes_to_upstream(struct branch *branch,\n> > +                                  const char *upstream)\n> > +{\n> > +     struct remote *remote = remote_get(remote_for_branch(branch, NULL));\n> > +     char *tracking = NULL;\n> > +     int ret = 0;\n> > +\n> > +     if (remote)\n> > +             tracking = apply_refspecs(&remote->fetch, branch->refname);\n>\n> This tells us which remote tracking ref corresponds to the branch\n>\n> > +     if (tracking && !strcmp(tracking, upstream))\n> > +             ret = 1;\n>\n> Here we check that it does not match the upstream branch. That ignores\n> the push refspect though so does not tell us whether pushing the branch\n> to the upstream remote would update the upstream branch on that remote.\n\n> We need to apply the push refspec to the local branch, apply the fetch\n> refspec in reverse to the result and then compare that to the upstream\n> branch.\n\nI'll try to do something about this, but I removed a big part of the\npush logic when introducing the stacked branches, it simplified things\na lot without it. But maybe too simplistic.\n\n> > +test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n> > +     setup_repo_for_delete_merged &&\n> > +     (\n> > +             cd repo &&\n> > +             git checkout --detach &&\n> > +\n> > +             git branch --delete-merged */* &&\n>\n> Unless I've missed something main does not have an upstream branch set,\n> so we'd never expect it to be deleted, even if a push remote was set.\n\nI'll take a look.\n\n> > +test_expect_success '--delete-merged deletes only selected merged branches' '\n> > +     setup_repo_for_delete_merged &&\n> > +     create_merged_branch also-merged &&\n> > +     create_merged_branch merged &&\n> > +     (\n> > +             cd repo &&\n> > +             git checkout -b unmerged origin/next --track &&\n>\n> In the tests we try to avoid mixing options and positional arguments so\n> we use either\n>         git checkout --track -b unmerged origin/next\n> or\n>         git checkout -b unmerged --track origin/next\n\nGood point.\n\n> > +test_expect_success '--delete-merged keeps main despite a different default push remote' '\n> > +     setup_repo_for_delete_merged &&\n> > +     create_merged_branch on-next &&\n> > +     create_merged_branch checked-out &&\n> > +     create_merged_branch upstream-gone &&\n> > +     (\n> > +             cd repo &&\n> > +             git config remote.pushDefault fork &&\n> > +             git checkout -b local-to-delete main --track &&\n> > +             git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n> > +             git branch --set-upstream-to=origin/topic upstream-gone &&\n> > +             git update-ref -d refs/remotes/origin/topic &&\n>\n> As I think I said last time, this is a very round-about way to have the\n> upstream gone. It would be much simpler just to set the config directly.\n\nGood point.\n\n> > +             git checkout -b tracks-other other/main --track &&\n> > +             git checkout checked-out &&\n> > +\n> > +             git branch --delete-merged origin/* \\\n> > +                     --delete-merged main &&\n>\n> These lines look very short why the wrapping?\n\nWill unwrap some of these.\n\n> Why do we keep \"lower\", rather than clear the upstream config of \"mid\"?\n\nWe keep lower to preserve the upstream chain of surviving tip.\n\n\n\nHarald\n"},{"id":"549280","messageId":"2574f79a-f04e-449f-aeb4-10d68cdc9437@gmail.com","threadId":"65574","inReplyTo":"CAHwyqnV_Uj3anbU9xRBtEUP3M84y9obK6+kx3c1s18NV2ta8eA@mail.gmail.com","subject":"Re: [PATCH v23 5/7] branch: add --delete-merged <branch>","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-07-30T09:32:13Z","receivedAt":"2026-07-30T09:32:18Z","isPatch":true,"body":"Hi Harald\n\nOn 30/07/2026 00:13, Harald Nordgren wrote:\n>>> @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n>>>        N_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n>>>        N_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n>>>        N_(\"git branch [<options>] [-r | -a] [--format]\"),\n>>> +     N_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n>>\n>> I don't quite follow this - why the \"()\" and doesn't --delete-merged\n>> take a pattern?\n> \n> I don't get this one, but would this be better?\n> \n> ```\n> git branch [--dry-run] --delete-merged <pattern>\n>     [--delete-merged <pattern>]... [<branch-pattern>...]\n\nI think I just didn't understand what \"(--option)...\" means - grepping \naround that does seem to be what we use for \"give this option at least \nonce\" so I think the original is fine.\n\n>>> +static int branch_pushes_to_upstream(struct branch *branch,\n>>> +                                  const char *upstream)\n>>> +{\n>>> +     struct remote *remote = remote_get(remote_for_branch(branch, NULL));\n>>> +     char *tracking = NULL;\n>>> +     int ret = 0;\n>>> +\n>>> +     if (remote)\n>>> +             tracking = apply_refspecs(&remote->fetch, branch->refname);\n>>\n>> This tells us which remote tracking ref corresponds to the branch\n>>\n>>> +     if (tracking && !strcmp(tracking, upstream))\n>>> +             ret = 1;\n>>\n>> Here we check that it does not match the upstream branch. That ignores\n>> the push refspect though so does not tell us whether pushing the branch\n>> to the upstream remote would update the upstream branch on that remote.\n> \n>> We need to apply the push refspec to the local branch, apply the fetch\n>> refspec in reverse to the result and then compare that to the upstream\n>> branch.\n\nOops I think that's wrong. We should apply the fetch refspec to the \nresult of the push refspec, not apply it in reverse.\n\nIf we want to pretend that origin uses \"main\" rather than \"master\" we \ncould set\n\n\tremote.origin.push refs/heads/main:refs/heads/master\n\tremote.origin.fetch refs/heads/master:refs/remotes/origin/main\n\nso we map refs/heads/main to refs/heads/master and then map that to \nrefs/remotes/origin/main. There's nothing (apart from maybe common \nsense) to say the refspecs have to be symmetric though.\n\n> I'll try to do something about this, but I removed a big part of the\n> push logic when introducing the stacked branches, it simplified things\n> a lot without it. But maybe too simplistic.\n\nMaybe I'm missing something but aren't the two concerns independent?\n\n>>> +             git checkout -b tracks-other other/main --track &&\n>>> +             git checkout checked-out &&\n>>> +\n>>> +             git branch --delete-merged origin/* \\\n>>> +                     --delete-merged main &&\n>>\n>> These lines look very short why the wrapping?\n> \n> Will unwrap some of these.\n> \n>> Why do we keep \"lower\", rather than clear the upstream config of \"mid\"?\n> \n> We keep lower to preserve the upstream chain of surviving tip.\n\nYes but why do we want to do that? It's a change in behavior from \nclearing the upstream of mid that wasn't really called out clearly as a \nchange in behavior for xxx reason in the cover letter of the iteration \nthat introduced it. Why is it desirable to keep the whole chain rather \nthan just the upstream of the unmerged branch?\n\nThanks\n\nPhillip\n"},{"id":"549293","messageId":"CAHwyqnUbo_oByuu_CXdqZ=6uVSnTOCXM+fyD-hDWUkDzP6iMsQ@mail.gmail.com","threadId":"65574","inReplyTo":"2574f79a-f04e-449f-aeb4-10d68cdc9437@gmail.com","subject":"Re: [PATCH v23 5/7] branch: add --delete-merged <branch>","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-07-30T11:45:32Z","receivedAt":"2026-07-30T11:46:12Z","isPatch":true,"body":"Thanks for all the help to drive this across the finish line!\n\n> >>> @@ -38,6 +39,7 @@ static const char * const builtin_branch_usage[] = {\n> >>>        N_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n> >>>        N_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n> >>>        N_(\"git branch [<options>] [-r | -a] [--format]\"),\n> >>> +     N_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n> >>\n> >> I don't quite follow this - why the \"()\" and doesn't --delete-merged\n> >> take a pattern?\n> >\n> > I don't get this one, but would this be better?\n> >\n> > ```\n> > git branch [--dry-run] --delete-merged <pattern>\n> >     [--delete-merged <pattern>]... [<branch-pattern>...]\n>\n> I think I just didn't understand what \"(--option)...\" means - grepping\n> around that does seem to be what we use for \"give this option at least\n> once\" so I think the original is fine.\n\nOk, will use the ().\n\n> >>> +static int branch_pushes_to_upstream(struct branch *branch,\n> >>> +                                  const char *upstream)\n> >>> +{\n> >>> +     struct remote *remote = remote_get(remote_for_branch(branch, NULL));\n> >>> +     char *tracking = NULL;\n> >>> +     int ret = 0;\n> >>> +\n> >>> +     if (remote)\n> >>> +             tracking = apply_refspecs(&remote->fetch, branch->refname);\n> >>\n> >> This tells us which remote tracking ref corresponds to the branch\n> >>\n> >>> +     if (tracking && !strcmp(tracking, upstream))\n> >>> +             ret = 1;\n> >>\n> >> Here we check that it does not match the upstream branch. That ignores\n> >> the push refspect though so does not tell us whether pushing the branch\n> >> to the upstream remote would update the upstream branch on that remote.\n> >\n> >> We need to apply the push refspec to the local branch, apply the fetch\n> >> refspec in reverse to the result and then compare that to the upstream\n> >> branch.\n>\n> Oops I think that's wrong. We should apply the fetch refspec to the\n> result of the push refspec, not apply it in reverse.\n>\n> If we want to pretend that origin uses \"main\" rather than \"master\" we\n> could set\n>\n>         remote.origin.push refs/heads/main:refs/heads/master\n>         remote.origin.fetch refs/heads/master:refs/remotes/origin/main\n>\n> so we map refs/heads/main to refs/heads/master and then map that to\n> refs/remotes/origin/main. There's nothing (apart from maybe common\n> sense) to say the refspecs have to be symmetric though.\n\nI'm writing logic for this and an integration test. Thanks!\n\n> >> Why do we keep \"lower\", rather than clear the upstream config of \"mid\"?\n> >\n> > We keep lower to preserve the upstream chain of surviving tip.\n>\n> Yes but why do we want to do that? It's a change in behavior from\n> clearing the upstream of mid that wasn't really called out clearly as a\n> change in behavior for xxx reason in the cover letter of the iteration\n> that introduced it. Why is it desirable to keep the whole chain rather\n> than just the upstream of the unmerged branch?\n\nI can do it and get rid of the graph traversal (visited), but I think\nwe are once again going back to an earlier iteration. One of the other\nreviewers wanted to protect transitive branches I think.\n\nEither way is fine, I think it's a pretty small detail in the grand\nscheme of things, I just don't want another round of ping-pongs on\nthis part.\n\n\nHarald\n"},{"id":"549300","messageId":"59157142b3b9f7fb5e04b07fcfd8e41283ec9473.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:30Z","receivedAt":"2026-07-30T13:58:39Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 +++++-\n ref-filter.c                  |  70 +++++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 115 ++++++++++++++++++++++++++++++++++\n 5 files changed, 222 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 031a4a9d05..1ab4356188 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -674,6 +674,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -794,6 +804,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -839,7 +852,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 29aca08ce7..bdf54f6f59 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\n+\tif (!skip_prefix(short_name, \"refs/heads/\", &short_name))\n+\t\tskip_prefix(short_name, \"refs/remotes/\", &short_name);\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (size_t i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 1ecbafbee1..eaadbdf549 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1755,4 +1755,119 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one --format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" --format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549302","messageId":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v23.git.git.1784979136.gitgitgadget@gmail.com","subject":"[PATCH v24 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:29Z","receivedAt":"2026-07-30T13:58:39Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v24:\n\n * Map branches through the configured push and fetch refspecs before\n   deciding whether pushing would update their upstream.\n * Protect only the direct upstream of surviving work, delete deeper merged\n   bases and clear stale upstream configuration.\n * Clarify that --delete-merged takes upstream patterns in docs.\n\nChanges in v23:\n\n * Use a loop-local size_t iterator for --forked patterns.\n * Simplify upstream-prefix stripping into an explicit fallback.\n\nChanges in v22:\n\n * Loop variable int when appropriate to avoid typecast.\n\nChanges in v21:\n\n * Change loop variables to prevent CI timeout.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <pattern>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  52 +++-\n builtin/branch.c                 | 312 +++++++++++++++++++++---\n ref-filter.c                     |  70 ++++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 401 +++++++++++++++++++++++++++++++\n 6 files changed, 821 insertions(+), 31 deletions(-)\n\n\nbase-commit: 13c7afec212fc97ce257d15601659314c6673d6c\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v24\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v24\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v23:\n\n 1:  0c4fe549cb ! 1:  59157142b3 branch: add --forked filter for --list mode\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked composes with --no-merged' '\n     -+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n     -+\t\t--format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one --format=\"%(refname:short)\" >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked narrows a <pattern> argument' '\n     -+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n     -+\t\t--format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"origin/*\" \"local-*\" --format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n      +\tlocal-two\n 2:  2d20015ba9 ! 2:  5d310da9aa branch: convert delete_branches() to a flags argument\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n      -\t\tint flags = 0;\n      +\t\tint ref_flags = 0;\n       \n     - \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n     - \t\tfree(name);\n     + \t\tcopy_branchname(the_repository, &bname,\n     + \t\t\t\targv[i], allowed_interpret);\n      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n       \t\t\t\t\t     RESOLVE_REF_READING\n       \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n 3:  34b37aeb43 = 3:  263e90f6ed branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  606ce4082c = 4:  22e31fd35c branch: prepare delete_branches for a bulk caller\n 5:  5fd74f0050 ! 5:  d9f93e66c9 branch: add --delete-merged <branch>\n     @@ Metadata\n      Author: Harald Nordgren <haraldnordgren@gmail.com>\n      \n       ## Commit message ##\n     -    branch: add --delete-merged <branch>\n     +    branch: add --delete-merged <pattern>\n      \n     -        git branch (--delete-merged <branch>)... [<pattern>...]\n     +        git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n      \n     -    deletes local branches matching the optional patterns when their\n     +    deletes local branches matching the optional branch patterns when their\n          configured upstream matches one of the --delete-merged arguments and\n          their tip is reachable from that upstream. The work has already landed\n          on the upstream they track, so the local copy is no longer needed.\n      \n     -    The option can be repeated to widen the upstream match. Keeping the\n     -    candidate patterns as positional arguments lets users bound the set of\n     -    local branches that may be deleted independently of the upstream\n     -    selection.\n     +    Each <pattern> may name a ref, a remote, or a shell glob. The option can\n     +    be repeated to widen the upstream match. Keeping the candidate patterns\n     +    as positional arguments lets users bound the set of local branches that\n     +    may be deleted independently of the upstream selection.\n      \n          A branch is not deleted when:\n      \n            * it is checked out in any worktree\n            * its configured upstream ref no longer exists, since a missing\n              upstream is not by itself a sign of integration\n     -      * pushing it by name to the remote configured by\n     -        branch.<name>.remote would update its upstream, as determined by\n     -        mapping the branch ref through that remote's fetch refspec. For\n     -        example, a local \"main\" that tracks \"origin/main\" is kept even when\n     -        remote.pushDefault names a fork. Right after a pull it merely looks\n     -        fully merged.\n     +      * pushing it to the remote configured by branch.<name>.remote would\n     +        update its upstream, as determined by that remote's configured push\n     +        and fetch refspecs. For example, a local \"main\" that tracks\n     +        \"origin/main\" is kept even when remote.pushDefault names a fork.\n     +        Right after a pull it merely looks fully merged.\n     +      * it is the local upstream of a branch that is not being deleted, so\n     +        no branch is deleted out from under stacked work.\n      \n          A branch whose work is not yet merged into its upstream is silently\n          skipped, so one unmerged topic does not abort the whole sweep.\n      \n     -    A branch that a surviving branch depends on through a chain of local\n     -    upstreams is also kept, so no branch is deleted out from under stacked\n     -    work. Collect this transitive set without changing the candidate set\n     -    during ref iteration: walk upstream chains from surviving branches,\n     -    visit each branch at most once, and remove the collected bases only\n     -    after the iteration completes. This makes the result independent of\n     -    ref iteration order without repeated full scans.\n     +    Collect protected local upstreams without changing the candidate set\n     +    during ref iteration, then remove them after iteration. This makes the\n     +    result independent of ref iteration order. If a protected branch's own\n     +    upstream is deleted by the same sweep, clear its upstream configuration.\n      \n          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n      \n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     -+git branch (--delete-merged <branch>)... [<pattern>...]\n     ++git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n       \n       DESCRIPTION\n       -----------\n     @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod\n       \tPrint the name of the current branch. In detached `HEAD` state,\n       \tnothing is printed.\n       \n     -+`--delete-merged <branch>`::\n     ++`--delete-merged <pattern>`::\n      +\tDelete local branches whose configured upstream matches\n     -+\t_<branch>_, but only when their tip is reachable from that\n     ++\t_<pattern>_, but only when their tip is reachable from that\n      +\tupstream. In other words, the work on the branch has already\n      +\tlanded on the upstream it tracks, so the local copy is no longer\n     -+\tneeded. The option can be repeated to widen the upstream match.\n     -+\tOptional _<pattern>_ arguments limit which local branches are\n     -+\tconsidered, e.g. `git branch --delete-merged 'origin/*'\n     ++\tneeded. _<pattern>_ may name a ref, a remote (using the branch its\n     ++\t`HEAD` points at), or a shell-style glob. The option can be\n     ++\trepeated to widen the upstream match.\n     ++\tOptional _<branch-pattern>_ arguments limit which local branches\n     ++\tare considered, e.g. `git branch --delete-merged 'origin/*'\n      +\t'topic-*'`.\n      ++\n      +A branch is not deleted when:\n      ++\n      +--\n      +* its configured upstream ref no longer exists,\n     -+* it is checked out in any worktree, or\n     -+* pushing it by name to the remote configured by\n     ++* it is checked out in any worktree,\n     ++* pushing it to the remote configured by\n      +  `branch.<name>.remote` would update its upstream, so it cannot be\n     -+  distinguished from a branch that just looks \"fully merged\" right\n     -+  after a pull.\n     ++  distinguished from a branch that just looks fully merged right\n     ++  after a pull; this is determined by the remote's configured push and\n     ++  fetch refspecs,\n     ++* it is the local upstream of a branch that is not being deleted.\n      +--\n      ++\n     ++When such a local upstream branch has its own upstream deleted by the\n     ++same operation, its upstream configuration is cleared.\n     +++\n      +A branch whose work has not yet been merged into its upstream is\n      +silently skipped. Delete it with `git branch -D` if you want to\n      +remove it anyway.\n     -++\n     -+A branch that a surviving branch depends on through a chain of local\n     -+upstreams is kept, so a branch is never deleted out from under stacked\n     -+work.\n      +\n       `-v`::\n       `-vv`::\n     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {\n       \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n       \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n     -+\tN_(\"git branch [<options>] (--delete-merged <branch>)... [<pattern>...]\"),\n     ++\tN_(\"git branch [<options>] (--delete-merged <pattern>)... \"\n     ++\t   \"[<branch-pattern>...]\"),\n       \tNULL\n       };\n       \n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +struct stacked_branch_data {\n      +\tstruct strset *deletable_branch_names;\n      +\tstruct strset *protected_branch_names;\n     -+\tstruct strset *visited_branch_names;\n      +};\n      +\n     -+static int collect_stacked_branch_bases(const struct reference *ref,\n     -+\t\t\t\t\tvoid *cb_data)\n     ++static int collect_stacked_branch_base(const struct reference *ref,\n     ++\t\t\t\t       void *cb_data)\n      +{\n      +\tstruct stacked_branch_data *data = cb_data;\n      +\tconst char *branch_name;\n     ++\tstruct branch *branch;\n     ++\tconst char *upstream_refname;\n     ++\tconst char *upstream_branch_name;\n      +\n      +\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n      +\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n      +\tif (strset_contains(data->deletable_branch_names, branch_name))\n      +\t\treturn 0;\n      +\n     -+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n     -+\t\tstruct branch *branch = branch_get(branch_name);\n     -+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n     -+\t\tconst char *upstream_branch_name;\n     -+\n     -+\t\tif (!upstream_refname ||\n     -+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n     -+\t\t\t\t &upstream_branch_name) ||\n     -+\t\t    !strset_contains(data->deletable_branch_names,\n     -+\t\t\t\t    upstream_branch_name))\n     -+\t\t\tbreak;\n     -+\n     -+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n     -+\t\tbranch_name = upstream_branch_name;\n     -+\t}\n     ++\tbranch = branch_get(branch_name);\n     ++\tupstream_refname = branch_get_upstream(branch, NULL);\n     ++\tif (!upstream_refname ||\n     ++\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n     ++\t\t\t &upstream_branch_name) ||\n     ++\t    !strset_contains(data->deletable_branch_names,\n     ++\t\t\t    upstream_branch_name))\n     ++\t\treturn 0;\n      +\n     ++\tstrset_add(data->protected_branch_names, upstream_branch_name);\n      +\treturn 0;\n      +}\n      +\n      +static void protect_stacked_branch_bases(struct ref_store *refs,\n     -+\t\t\t\t\t struct strset *deletable_branch_names)\n     ++\t\t\t\t\t struct strset *deletable_branch_names,\n     ++\t\t\t\t\t struct strset *protected_branch_names)\n      +{\n     -+\tstruct strset protected_branch_names = STRSET_INIT;\n     -+\tstruct strset visited_branch_names = STRSET_INIT;\n      +\tstruct stacked_branch_data data = {\n      +\t\t.deletable_branch_names = deletable_branch_names,\n     -+\t\t.protected_branch_names = &protected_branch_names,\n     -+\t\t.visited_branch_names = &visited_branch_names,\n     ++\t\t.protected_branch_names = protected_branch_names,\n      +\t};\n      +\tstruct refs_for_each_ref_options opts = {\n      +\t\t.prefix = \"refs/heads/\",\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n      +\n     -+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n     ++\trefs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);\n      +\n     -+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n     ++\tstrset_for_each_entry(protected_branch_names, &iter, entry)\n      +\t\tstrset_remove(deletable_branch_names, entry->key);\n     ++}\n      +\n     -+\tstrset_clear(&visited_branch_names);\n     -+\tstrset_clear(&protected_branch_names);\n     ++static void clear_deleted_upstreams(struct strset *protected_branch_names,\n     ++\t\t\t\t    struct strset *deletable_branch_names)\n     ++{\n     ++\tstruct strbuf key = STRBUF_INIT;\n     ++\tstruct hashmap_iter iter;\n     ++\tstruct strmap_entry *entry;\n     ++\n     ++\tstrset_for_each_entry(protected_branch_names, &iter, entry) {\n     ++\t\tstruct branch *branch = branch_get(entry->key);\n     ++\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n     ++\t\tconst char *upstream_branch_name;\n     ++\n     ++\t\tif (!upstream_refname ||\n     ++\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n     ++\t\t\t\t &upstream_branch_name) ||\n     ++\t\t    !strset_contains(deletable_branch_names,\n     ++\t\t\t\t    upstream_branch_name))\n     ++\t\t\tcontinue;\n     ++\n     ++\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n     ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\tstrbuf_reset(&key);\n     ++\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n     ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n     ++\t\tstrbuf_reset(&key);\n     ++\t}\n     ++\n     ++\tstrbuf_release(&key);\n      +}\n      +\n      +static int branch_pushes_to_upstream(struct branch *branch,\n      +\t\t\t\t     const char *upstream)\n      +{\n      +\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n     ++\tchar *push_refname = NULL;\n      +\tchar *tracking = NULL;\n      +\tint ret = 0;\n      +\n     -+\tif (remote)\n     -+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n     ++\tif (!remote)\n     ++\t\treturn 0;\n     ++\tif (remote->push.nr)\n     ++\t\tpush_refname = apply_refspecs(&remote->push, branch->refname);\n     ++\telse\n     ++\t\tpush_refname = xstrdup(branch->refname);\n     ++\tif (push_refname)\n     ++\t\ttracking = apply_refspecs(&remote->fetch, push_refname);\n      +\tif (tracking && !strcmp(tracking, upstream))\n      +\t\tret = 1;\n      +\n     ++\tfree(push_refname);\n      +\tfree(tracking);\n      +\treturn ret;\n      +}\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\tstruct ref_filter filter = REF_FILTER_INIT;\n      +\tstruct ref_array candidates = { 0 };\n      +\tstruct strset deletable_branch_names = STRSET_INIT;\n     ++\tstruct strset protected_branch_names = STRSET_INIT;\n      +\tstruct strvec branches_to_delete = STRVEC_INIT;\n      +\tstruct hashmap_iter iter;\n      +\tstruct strmap_entry *entry;\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\tstrset_add(&deletable_branch_names, branch_name);\n      +\t}\n      +\n     -+\tprotect_stacked_branch_bases(refs, &deletable_branch_names);\n     ++\tprotect_stacked_branch_bases(refs, &deletable_branch_names,\n     ++\t\t\t\t     &protected_branch_names);\n      +\n      +\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n      +\t\tstrvec_push(&branches_to_delete, entry->key);\n     @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n      +\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n      +\t\t\t\t      flags);\n      +\n     ++\tif (!ret)\n     ++\t\tclear_deleted_upstreams(&protected_branch_names,\n     ++\t\t\t\t\t&deletable_branch_names);\n     ++\n      +\tstrvec_clear(&branches_to_delete);\n     ++\tstrset_clear(&protected_branch_names);\n      +\tstrset_clear(&deletable_branch_names);\n      +\tref_array_clear(&candidates);\n      +\tref_filter_clear(&filter);\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n       \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n       \t\t\t N_(\"edit the description for the branch\")),\n     -+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n     -+\t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n     ++\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n     ++\t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n      +\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n       \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n       \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +create_merged_branch () {\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b \"$1\" origin/next --track &&\n     ++\t\tgit checkout -b \"$1\" --track origin/next &&\n      +\t\tgit commit --allow-empty -m \"$1 work\" &&\n      +\t\tgit push origin \"$1:next\"\n      +\t)\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\ttest_cmp expect actual\n      +}\n      +\n     -+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n     ++test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '\n      +\tsetup_repo_for_delete_merged &&\n      +\t(\n      +\t\tcd repo &&\n     ++\t\ttest_cmp_config origin branch.main.remote &&\n     ++\t\ttest_cmp_config refs/heads/main branch.main.merge &&\n      +\t\tgit checkout --detach &&\n      +\n      +\t\tgit branch --delete-merged */* &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tcreate_merged_branch merged &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b unmerged origin/next --track &&\n     ++\t\tgit checkout -b unmerged --track origin/next &&\n      +\t\tgit commit --allow-empty -m \"unmerged work\" &&\n     -+\t\tgit checkout -b tracks-other other/main --track &&\n     ++\t\tgit checkout -b tracks-other --track other/main &&\n      +\t\tsha=$(git rev-parse --short merged) &&\n      +\n      +\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t(\n      +\t\tcd repo &&\n      +\t\tgit config remote.pushDefault fork &&\n     -+\t\tgit checkout -b local-to-delete main --track &&\n     -+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n     -+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n     -+\t\tgit update-ref -d refs/remotes/origin/topic &&\n     -+\t\tgit checkout -b tracks-other other/main --track &&\n     ++\t\tgit checkout -b local-to-delete --track main &&\n     ++\t\tgit config branch.upstream-gone.merge refs/heads/topic &&\n     ++\t\tgit checkout -b tracks-other --track other/main &&\n      +\t\tgit checkout checked-out &&\n      +\n     -+\t\tgit branch --delete-merged origin/* \\\n     -+\t\t\t--delete-merged main &&\n     ++\t\tgit branch --delete-merged origin/* --delete-merged main &&\n      +\n      +\t\tcheck_branches <<-\\EOF\n      +\t\tchecked-out\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t)\n      +'\n      +\n     ++test_expect_success '--delete-merged maps push refspecs to upstreams' '\n     ++\tsetup_repo_for_delete_merged &&\n     ++\t(\n     ++\t\tcd repo &&\n     ++\t\tgit checkout -b topic &&\n     ++\t\tgit config remote.origin.push \"refs/heads/topic:refs/heads/published\" &&\n     ++\t\tgit push origin &&\n     ++\t\tgit branch --set-upstream-to=origin/published topic &&\n     ++\t\tgit checkout -b other-topic --track origin/published &&\n     ++\t\tgit checkout --detach &&\n     ++\n     ++\t\tgit branch --delete-merged origin/published &&\n     ++\n     ++\t\tcheck_branches <<-\\EOF\n     ++\t\tmain\n     ++\t\ttopic\n     ++\t\tEOF\n     ++\t)\n     ++'\n     ++\n      +test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n      +\tsetup_repo_for_delete_merged &&\n      +\tcreate_merged_branch feature &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit checkout -b topic feature --track &&\n     ++\t\tgit checkout -b topic --track feature &&\n      +\t\tgit commit --allow-empty -m \"topic work\" &&\n      +\n      +\t\tgit branch --delete-merged origin/next 2>err &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t)\n      +'\n      +\n     -+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n     ++test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '\n      +\tsetup_repo_for_delete_merged &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit config remote.pushDefault fork &&\n     -+\t\tgit branch lower origin/next --track &&\n     -+\t\tgit branch mid lower --track &&\n     -+\t\tgit checkout -b tip mid --track &&\n     ++\t\tgit branch --track lower origin/next &&\n     ++\t\tgit branch --track mid lower &&\n     ++\t\tgit checkout -b tip --track mid &&\n      +\t\tgit commit --allow-empty -m \"tip work\" &&\n     ++\t\tsha=$(git rev-parse --short lower) &&\n      +\n     -+\t\tgit branch --delete-merged origin/next \\\n     -+\t\t\t--delete-merged lower >actual 2>&1 &&\n     -+\t\ttest_must_be_empty actual &&\n     ++\t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     ++\t\techo \"Deleted branch lower (was $sha).\" >expect &&\n     ++\t\ttest_cmp expect actual &&\n      +\n      +\t\tcheck_branches <<-\\EOF &&\n     -+\t\tlower\n      +\t\tmain\n      +\t\tmid\n      +\t\ttip\n      +\t\tEOF\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n     -+\t\tbranch.lower.remote origin\n     -+\t\tbranch.lower.merge refs/heads/next\n     -+\t\tbranch.mid.remote .\n     -+\t\tbranch.mid.merge refs/heads/lower\n      +\t\tbranch.tip.remote .\n      +\t\tbranch.tip.merge refs/heads/mid\n      +\t\tEOF\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\tsetup_repo_for_delete_merged &&\n      +\t(\n      +\t\tcd repo &&\n     -+\t\tgit branch c-lower origin/next --track &&\n     -+\t\tgit branch b-mid c-lower --track &&\n     -+\t\tgit checkout -b a-tip b-mid --track &&\n     ++\t\tgit branch --track c-lower origin/next &&\n     ++\t\tgit branch --track b-mid c-lower &&\n     ++\t\tgit checkout -b a-tip --track b-mid &&\n      +\t\tgit commit --allow-empty -m \"tip work\" &&\n      +\n     -+\t\tgit branch --delete-merged origin/next \\\n     -+\t\t\t--delete-merged \"c-*\" &&\n     ++\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" &&\n      +\n      +\t\tcheck_branches <<-\\EOF &&\n      +\t\ta-tip\n      +\t\tb-mid\n     -+\t\tc-lower\n      +\t\tmain\n      +\t\tEOF\n      +\n     -+\t\tgit branch --delete-merged origin/next \\\n     -+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n     ++\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" >actual 2>&1 &&\n      +\t\ttest_must_be_empty actual &&\n      +\n      +\t\tcheck_branches <<-\\EOF\n      +\t\ta-tip\n      +\t\tb-mid\n     -+\t\tc-lower\n      +\t\tmain\n      +\t\tEOF\n      +\t)\n 6:  6494be7c75 ! 6:  aa845096d8 branch: add branch.<name>.deleteMerged opt-out\n     @@ Documentation/config/branch.adoc: for details).\n      \n       ## Documentation/git-branch.adoc ##\n      @@ Documentation/git-branch.adoc: A branch is not deleted when:\n     - +\n     - --\n     - * its configured upstream ref no longer exists,\n     --* it is checked out in any worktree, or\n     -+* it is checked out in any worktree,\n     - * pushing it by name to the remote configured by\n     -   `branch.<name>.remote` would update its upstream, so it cannot be\n     -   distinguished from a branch that just looks \"fully merged\" right\n     --  after a pull.\n     -+  after a pull, or\n     +   distinguished from a branch that just looks fully merged right\n     +   after a pull; this is determined by the remote's configured push and\n     +   fetch refspecs,\n     +-* it is the local upstream of a branch that is not being deleted.\n     ++* it is the local upstream of a branch that is not being deleted, or\n      +* `branch.<name>.deleteMerged` is set to `false`.\n       --\n       +\n     - A branch whose work has not yet been merged into its upstream is\n     + When such a local upstream branch has its own upstream deleted by the\n      \n       ## builtin/branch.c ##\n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     - \tstruct ref_array candidates = { 0 };\n       \tstruct strset deletable_branch_names = STRSET_INIT;\n     + \tstruct strset protected_branch_names = STRSET_INIT;\n       \tstruct strvec branches_to_delete = STRVEC_INIT;\n      +\tstruct strbuf key = STRBUF_INIT;\n       \tstruct hashmap_iter iter;\n     @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea\n       \t}\n       \n      @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     - \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     - \t\t\t\t      flags);\n     + \t\tclear_deleted_upstreams(&protected_branch_names,\n     + \t\t\t\t\t&deletable_branch_names);\n       \n      +\tstrbuf_release(&key);\n       \tstrvec_clear(&branches_to_delete);\n     + \tstrset_clear(&protected_branch_names);\n       \tstrset_clear(&deletable_branch_names);\n     - \tref_array_clear(&candidates);\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '\n 7:  7102c931e2 ! 7:  96ebfd6ec6 branch: add --dry-run for --delete-merged\n     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n       git branch (-c|-C) [<old-branch>] <new-branch>\n       git branch (-d|-D) [-r] <branch-name>...\n       git branch --edit-description [<branch-name>]\n     --git branch (--delete-merged <branch>)... [<pattern>...]\n     -+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n     +-git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n     ++git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]\n       \n       DESCRIPTION\n       -----------\n     -@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local\n     - upstreams is kept, so a branch is never deleted out from under stacked\n     - work.\n     +@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is\n     + silently skipped. Delete it with `git branch -D` if you want to\n     + remove it anyway.\n       \n      +`--dry-run`::\n      +\tWith `--delete-merged`, print which branches would be\n     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n       \t\t\tchar *refname = name + branch_name_pos;\n       \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n       \t\t\t\tprintf(remote_branch\n     +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n     + \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n     + \t\t\t\t      flags);\n     + \n     +-\tif (!ret)\n     ++\tif (!ret && !(flags & DELETE_BRANCH_DRY_RUN))\n     + \t\tclear_deleted_upstreams(&protected_branch_names,\n     + \t\t\t\t\t&deletable_branch_names);\n     + \n      @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint delete = 0, rename = 0, copy = 0, list = 0,\n       \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n     @@ builtin/branch.c: int cmd_branch(int argc,\n       \tint noncreate_actions = 0;\n       \t/* possible options */\n      @@ builtin/branch.c: int cmd_branch(int argc,\n     - \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n     - \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n     + \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n     + \t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n       \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n      +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n      +\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n     @@ builtin/branch.c: int cmd_branch(int argc,\n      \n       ## t/t3200-branch.sh ##\n      @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '\n     - \t\tgit checkout -b tracks-other other/main --track &&\n     + \t\tgit checkout -b tracks-other --track other/main &&\n       \t\tsha=$(git rev-parse --short merged) &&\n       \n      +\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me\n       \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n       \t\ttest_cmp expect actual &&\n      @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n     - \t\tgit checkout -b topic feature --track &&\n     + \t\tgit checkout -b topic --track feature &&\n       \t\tgit commit --allow-empty -m \"topic work\" &&\n       \n      -\t\tgit branch --delete-merged origin/next 2>err &&\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a\n       \t\tcheck_branches <<-\\EOF &&\n       \t\tfeature\n       \t\tmain\n     -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n     - \t\tgit checkout -b tip mid --track &&\n     +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a protected\n       \t\tgit commit --allow-empty -m \"tip work\" &&\n     + \t\tsha=$(git rev-parse --short lower) &&\n       \n     -+\t\tgit branch --dry-run --delete-merged origin/next \\\n     -+\t\t\t--delete-merged lower >actual 2>&1 &&\n     -+\t\ttest_must_be_empty actual &&\n     ++\t\tgit branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     ++\t\techo \"Would delete branch lower (was $sha).\" >expect &&\n     ++\t\ttest_cmp expect actual &&\n      +\n      +\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain\n      +\t\tEOF\n      +\t\ttest_cmp expect actual &&\n      +\n     - \t\tgit branch --delete-merged origin/next \\\n     - \t\t\t--delete-merged lower >actual 2>&1 &&\n     - \t\ttest_must_be_empty actual &&\n     + \t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     + \t\techo \"Deleted branch lower (was $sha).\" >expect &&\n     + \t\ttest_cmp expect actual &&\n      @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n       \t)\n       '\n\n-- \ngitgitgadget\n"},{"id":"549301","messageId":"5d310da9aab4c969a21c409ed35d9913fda78f5d.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:31Z","receivedAt":"2026-07-30T13:58:40Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ab4356188..db7cb01190 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(the_repository, &bname,\n \t\t\t\targv[i], allowed_interpret);\n@@ -280,7 +286,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -292,7 +298,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -307,16 +313,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -331,7 +337,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -896,7 +902,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"549303","messageId":"263e90f6ed63ad3c9fc7a383a34836cdd959b985.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:32Z","receivedAt":"2026-07-30T13:58:41Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex db7cb01190..c44f710a48 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -316,7 +320,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"549304","messageId":"22e31fd35c2791f0f1ff7b6fc015aa5e24dea87a.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:33Z","receivedAt":"2026-07-30T13:58:43Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c44f710a48..7b0aa68572 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"549305","messageId":"d9f93e66c94f6a44052bd00b5c49ef9f663bfff2.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 5/7] branch: add --delete-merged <pattern>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:34Z","receivedAt":"2026-07-30T13:58:44Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n\ndeletes local branches matching the optional branch patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nEach <pattern> may name a ref, a remote, or a shell glob. The option can\nbe repeated to widen the upstream match. Keeping the candidate patterns\nas positional arguments lets users bound the set of local branches that\nmay be deleted independently of the upstream selection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it to the remote configured by branch.<name>.remote would\n    update its upstream, as determined by that remote's configured push\n    and fetch refspecs. For example, a local \"main\" that tracks\n    \"origin/main\" is kept even when remote.pushDefault names a fork.\n    Right after a pull it merely looks fully merged.\n  * it is the local upstream of a branch that is not being deleted, so\n    no branch is deleted out from under stacked work.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nCollect protected local upstreams without changing the candidate set\nduring ref iteration, then remove them after iteration. This makes the\nresult independent of ref iteration order. If a protected branch's own\nupstream is deleted by the same sweep, clear its upstream configuration.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  33 ++++++\n builtin/branch.c              | 195 ++++++++++++++++++++++++++++++-\n t/t3200-branch.sh             | 214 ++++++++++++++++++++++++++++++++++\n 3 files changed, 440 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..4766178204 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,38 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <pattern>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<pattern>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. _<pattern>_ may name a ref, a remote (using the branch its\n+\t`HEAD` points at), or a shell-style glob. The option can be\n+\trepeated to widen the upstream match.\n+\tOptional _<branch-pattern>_ arguments limit which local branches\n+\tare considered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree,\n+* pushing it to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks fully merged right\n+  after a pull; this is determined by the remote's configured push and\n+  fetch refspecs,\n+* it is the local upstream of a branch that is not being deleted.\n+--\n++\n+When such a local upstream branch has its own upstream deleted by the\n+same operation, its upstream configuration is cleared.\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 7b0aa68572..f1a73bcea1 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,8 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <pattern>)... \"\n+\t   \"[<branch-pattern>...]\"),\n \tNULL\n };\n \n@@ -700,6 +703,184 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+};\n+\n+static int collect_stacked_branch_base(const struct reference *ref,\n+\t\t\t\t       void *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\tstruct branch *branch;\n+\tconst char *upstream_refname;\n+\tconst char *upstream_branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\tbranch = branch_get(branch_name);\n+\tupstream_refname = branch_get_upstream(branch, NULL);\n+\tif (!upstream_refname ||\n+\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t &upstream_branch_name) ||\n+\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t    upstream_branch_name))\n+\t\treturn 0;\n+\n+\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names,\n+\t\t\t\t\t struct strset *protected_branch_names)\n+{\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = protected_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);\n+\n+\tstrset_for_each_entry(protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+}\n+\n+static void clear_deleted_upstreams(struct strset *protected_branch_names,\n+\t\t\t\t    struct strset *deletable_branch_names)\n+{\n+\tstruct strbuf key = STRBUF_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\tstrset_for_each_entry(protected_branch_names, &iter, entry) {\n+\t\tstruct branch *branch = branch_get(entry->key);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tcontinue;\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t}\n+\n+\tstrbuf_release(&key);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *push_refname = NULL;\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (!remote)\n+\t\treturn 0;\n+\tif (remote->push.nr)\n+\t\tpush_refname = apply_refspecs(&remote->push, branch->refname);\n+\telse\n+\t\tpush_refname = xstrdup(branch->refname);\n+\tif (push_refname)\n+\t\ttracking = apply_refspecs(&remote->fetch, push_refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(push_refname);\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint ret = 0;\n+\n+\tfor (size_t i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (int i = 0; i < candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names,\n+\t\t\t\t     &protected_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tif (!ret)\n+\t\tclear_deleted_upstreams(&protected_branch_names,\n+\t\t\t\t\t&deletable_branch_names);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&protected_branch_names);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -764,6 +945,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -817,6 +999,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -864,7 +1049,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -874,7 +1060,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -916,6 +1102,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1087,6 +1277,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex eaadbdf549..322c5a20cc 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1870,4 +1870,218 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" --track origin/next &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_cmp_config origin branch.main.remote &&\n+\t\ttest_cmp_config refs/heads/main branch.main.merge &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged --track origin/next &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other --track other/main &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete --track main &&\n+\t\tgit config branch.upstream-gone.merge refs/heads/topic &&\n+\t\tgit checkout -b tracks-other --track other/main &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* --delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged maps push refspecs to upstreams' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic &&\n+\t\tgit config remote.origin.push \"refs/heads/topic:refs/heads/published\" &&\n+\t\tgit push origin &&\n+\t\tgit branch --set-upstream-to=origin/published topic &&\n+\t\tgit checkout -b other-topic --track origin/published &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/published &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic --track feature &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch --track lower origin/next &&\n+\t\tgit branch --track mid lower &&\n+\t\tgit checkout -b tip --track mid &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\t\tsha=$(git rev-parse --short lower) &&\n+\n+\t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n+\t\techo \"Deleted branch lower (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch --track c-lower origin/next &&\n+\t\tgit branch --track b-mid c-lower &&\n+\t\tgit checkout -b a-tip --track b-mid &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549306","messageId":"aa845096d853b5613059433429dbcf4a44804047.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:35Z","receivedAt":"2026-07-30T13:58:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  3 ++-\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 59 insertions(+), 1 deletion(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex a4db9fa5c8..d8483acb4f 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -102,3 +102,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 4766178204..cfaac4b90f 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -224,7 +224,8 @@ A branch is not deleted when:\n   distinguished from a branch that just looks fully merged right\n   after a pull; this is determined by the remote's configured push and\n   fetch refspecs,\n-* it is the local upstream of a branch that is not being deleted.\n+* it is the local upstream of a branch that is not being deleted, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n When such a local upstream branch has its own upstream deleted by the\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f1a73bcea1..2d0c4f51ea 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -818,6 +818,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strset protected_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tint ret = 0;\n@@ -836,6 +837,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -853,6 +855,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -873,6 +886,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tclear_deleted_upstreams(&protected_branch_names,\n \t\t\t\t\t&deletable_branch_names);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&protected_branch_names);\n \tstrset_clear(&deletable_branch_names);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 322c5a20cc..50fd1d348c 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2084,4 +2084,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549307","messageId":"96ebfd6ec65253d9c86edcb2d8cee07f0dfd6af0.1785419916.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"[PATCH v24 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T13:58:36Z","receivedAt":"2026-07-30T13:58:47Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 +++++++-\n builtin/branch.c              | 23 +++++++++++++++++----\n t/t3200-branch.sh             | 38 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 63 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex cfaac4b90f..bfdf459329 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n+git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]\n \n DESCRIPTION\n -----------\n@@ -235,6 +235,12 @@ A branch whose work has not yet been merged into its upstream is\n silently skipped. Delete it with `git branch -D` if you want to\n remove it anyway.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2d0c4f51ea..57ee384d23 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -200,6 +200,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -342,13 +343,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -882,7 +890,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n-\tif (!ret)\n+\tif (!ret && !(flags & DELETE_BRANCH_DRY_RUN))\n \t\tclear_deleted_upstreams(&protected_branch_names,\n \t\t\t\t\t&deletable_branch_names);\n \n@@ -960,6 +968,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1016,6 +1025,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1078,6 +1089,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1118,7 +1132,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 50fd1d348c..942c7b63f8 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1938,6 +1938,19 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other --track other/main &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -2003,9 +2016,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic --track feature &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2033,6 +2049,21 @@ test_expect_success '--delete-merged clears the deleted upstream of a protected\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \t\tsha=$(git rev-parse --short lower) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n+\t\techo \"Would delete branch lower (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n \t\techo \"Deleted branch lower (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -2120,4 +2151,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"549575","messageId":"0ea7bbec-3381-4bc6-99a9-2c3b5d1bfebd@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v24.git.git.1785419916.gitgitgadget@gmail.com","subject":"Re: [PATCH v24 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-04T15:58:16Z","receivedAt":"2026-08-04T15:58:21Z","isPatch":true,"body":"Hi Harald\n\nOn 30/07/2026 14:58, Harald Nordgren via GitGitGadget wrote:\n> Changes in v24:\n> \n>   * Map branches through the configured push and fetch refspecs before\n>     deciding whether pushing would update their upstream.\n>   * Protect only the direct upstream of surviving work, delete deeper merged\n>     bases and clear stale upstream configuration.\n>   * Clarify that --delete-merged takes upstream patterns in docs.\n> \n> Range-diff vs v23:\n> \n>   1:  0c4fe549cb ! 1:  59157142b3 branch: add --forked filter for --list mode\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +'\n>        +\n>        +test_expect_success '--forked composes with --no-merged' '\n>       -+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one \\\n>       -+\t\t--format=\"%(refname:short)\" >actual &&\n>       ++\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one --format=\"%(refname:short)\" >actual &&\n\nWe wrap our lines at 80 columns. I know I suggested unwrapping some very \nshort lines in another patch, but these are far too long.\n\n>        +\techo local-one >expect &&\n>        +\ttest_cmp expect actual\n>        +'\n>       @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n>        +'\n>        +\n>        +test_expect_success '--forked narrows a <pattern> argument' '\n>       -+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n>       -+\t\t--format=\"%(refname:short)\" >actual &&\n>       ++\tgit -C forked branch --forked \"origin/*\" \"local-*\" --format=\"%(refname:short)\" >actual &&\n>        +\tcat >expect <<-\\EOF &&\n>        +\tlocal-one\n>        +\tlocal-two\n>   2:  2d20015ba9 ! 2:  5d310da9aa branch: convert delete_branches() to a flags argument\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo\n>        -\t\tint flags = 0;\n>        +\t\tint ref_flags = 0;\n>         \n>       - \t\tcopy_branchname(&bname, argv[i], allowed_interpret);\n>       - \t\tfree(name);\n>       + \t\tcopy_branchname(the_repository, &bname,\n>       + \t\t\t\targv[i], allowed_interpret);\n>        @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,\n>         \t\t\t\t\t     RESOLVE_REF_READING\n>         \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n>   3:  34b37aeb43 = 3:  263e90f6ed branch: let delete_branches skip unmerged branches on bulk refusal\n>   4:  606ce4082c = 4:  22e31fd35c branch: prepare delete_branches for a bulk caller\n>   5:  5fd74f0050 ! 5:  d9f93e66c9 branch: add --delete-merged <branch>\n>   [...]\n\nAs far as I can see, from here ...\n\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +struct stacked_branch_data {\n>        +\tstruct strset *deletable_branch_names;\n>        +\tstruct strset *protected_branch_names;\n>       -+\tstruct strset *visited_branch_names;\n>        +};\n>        +\n>       -+static int collect_stacked_branch_bases(const struct reference *ref,\n>       -+\t\t\t\t\tvoid *cb_data)\n>       ++static int collect_stacked_branch_base(const struct reference *ref,\n>       ++\t\t\t\t       void *cb_data)\n>        +{\n>        +\tstruct stacked_branch_data *data = cb_data;\n>        +\tconst char *branch_name;\n>       ++\tstruct branch *branch;\n>       ++\tconst char *upstream_refname;\n>       ++\tconst char *upstream_branch_name;\n>        +\n>        +\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n>        +\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n>        +\tif (strset_contains(data->deletable_branch_names, branch_name))\n>        +\t\treturn 0;\n>        +\n>       -+\twhile (strset_add(data->visited_branch_names, branch_name)) {\n>       -+\t\tstruct branch *branch = branch_get(branch_name);\n>       -+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n>       -+\t\tconst char *upstream_branch_name;\n>       -+\n>       -+\t\tif (!upstream_refname ||\n>       -+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n>       -+\t\t\t\t &upstream_branch_name) ||\n>       -+\t\t    !strset_contains(data->deletable_branch_names,\n>       -+\t\t\t\t    upstream_branch_name))\n>       -+\t\t\tbreak;\n>       -+\n>       -+\t\tstrset_add(data->protected_branch_names, upstream_branch_name);\n>       -+\t\tbranch_name = upstream_branch_name;\n>       -+\t}\n>       ++\tbranch = branch_get(branch_name);\n>       ++\tupstream_refname = branch_get_upstream(branch, NULL);\n>       ++\tif (!upstream_refname ||\n>       ++\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n>       ++\t\t\t &upstream_branch_name) ||\n>       ++\t    !strset_contains(data->deletable_branch_names,\n>       ++\t\t\t    upstream_branch_name))\n>       ++\t\treturn 0;\n>        +\n>       ++\tstrset_add(data->protected_branch_names, upstream_branch_name);\n>        +\treturn 0;\n>        +}\n>        +\n>        +static void protect_stacked_branch_bases(struct ref_store *refs,\n>       -+\t\t\t\t\t struct strset *deletable_branch_names)\n>       ++\t\t\t\t\t struct strset *deletable_branch_names,\n>       ++\t\t\t\t\t struct strset *protected_branch_names)\n>        +{\n>       -+\tstruct strset protected_branch_names = STRSET_INIT;\n>       -+\tstruct strset visited_branch_names = STRSET_INIT;\n>        +\tstruct stacked_branch_data data = {\n>        +\t\t.deletable_branch_names = deletable_branch_names,\n>       -+\t\t.protected_branch_names = &protected_branch_names,\n>       -+\t\t.visited_branch_names = &visited_branch_names,\n>       ++\t\t.protected_branch_names = protected_branch_names,\n>        +\t};\n>        +\tstruct refs_for_each_ref_options opts = {\n>        +\t\t.prefix = \"refs/heads/\",\n>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>        +\tstruct hashmap_iter iter;\n>        +\tstruct strmap_entry *entry;\n>        +\n>       -+\trefs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);\n>       ++\trefs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);\n>        +\n>       -+\tstrset_for_each_entry(&protected_branch_names, &iter, entry)\n>       ++\tstrset_for_each_entry(protected_branch_names, &iter, entry)\n>        +\t\tstrset_remove(deletable_branch_names, entry->key);\n>       ++}\n>        +\n>       -+\tstrset_clear(&visited_branch_names);\n>       -+\tstrset_clear(&protected_branch_names);\n>       ++static void clear_deleted_upstreams(struct strset *protected_branch_names,\n>       ++\t\t\t\t    struct strset *deletable_branch_names)\n>       ++{\n>       ++\tstruct strbuf key = STRBUF_INIT;\n>       ++\tstruct hashmap_iter iter;\n>       ++\tstruct strmap_entry *entry;\n>       ++\n>       ++\tstrset_for_each_entry(protected_branch_names, &iter, entry) {\n>       ++\t\tstruct branch *branch = branch_get(entry->key);\n>       ++\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n>       ++\t\tconst char *upstream_branch_name;\n>       ++\n>       ++\t\tif (!upstream_refname ||\n>       ++\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n>       ++\t\t\t\t &upstream_branch_name) ||\n>       ++\t\t    !strset_contains(deletable_branch_names,\n>       ++\t\t\t\t    upstream_branch_name))\n>       ++\t\t\tcontinue;\n>       ++\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n>       ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       ++\t\tstrbuf_reset(&key);\n>       ++\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n>       ++\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n>       ++\t\tstrbuf_reset(&key);\n>       ++\t}\n>       ++\n>       ++\tstrbuf_release(&key);\n>        +}\n\n... to here pretty much takes us back to V19 with the difference that we \nonly prune protected branches from the deleteable set after we've \nvisited all the branches - looks good.\n\n>        +static int branch_pushes_to_upstream(struct branch *branch,\n>        +\t\t\t\t     const char *upstream)\n>        +{\n>        +\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n>       ++\tchar *push_refname = NULL;\n>        +\tchar *tracking = NULL;\n>        +\tint ret = 0;\n>        +\n>       -+\tif (remote)\n>       -+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n>       ++\tif (!remote)\n>       ++\t\treturn 0;\n>       ++\tif (remote->push.nr)\n>       ++\t\tpush_refname = apply_refspecs(&remote->push, branch->refname);\n>       ++\telse\n>       ++\t\tpush_refname = xstrdup(branch->refname);\n>       ++\tif (push_refname)\n>       ++\t\ttracking = apply_refspecs(&remote->fetch, push_refname);\n>        +\tif (tracking && !strcmp(tracking, upstream))\n>        +\t\tret = 1;\n>       ++\tfree(push_refname);>        +\tfree(tracking);\n>        +\treturn ret;\n>        +}\n\nThis looks good now - we map the branch name with the push refspec if \nthere is one and then with the fetch refspec.\n\nThe test changes below look good as well.\n\nApart from the overlong lines in patch 1 which I'm not sure are worth a \nre-roll on their own, this looks ready to be merged to me\n\nThanks\n\nPhillip\n\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +create_merged_branch () {\n>        +\t(\n>        +\t\tcd repo &&\n>       -+\t\tgit checkout -b \"$1\" origin/next --track &&\n>       ++\t\tgit checkout -b \"$1\" --track origin/next &&\n>        +\t\tgit commit --allow-empty -m \"$1 work\" &&\n>        +\t\tgit push origin \"$1:next\"\n>        +\t)\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\ttest_cmp expect actual\n>        +}\n>        +\n>       -+test_expect_success '--delete-merged keeps cloned main without a default push remote' '\n>       ++test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '\n>        +\tsetup_repo_for_delete_merged &&\n>        +\t(\n>        +\t\tcd repo &&\n>       ++\t\ttest_cmp_config origin branch.main.remote &&\n>       ++\t\ttest_cmp_config refs/heads/main branch.main.merge &&\n>        +\t\tgit checkout --detach &&\n>        +\n>        +\t\tgit branch --delete-merged */* &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tcreate_merged_branch merged &&\n>        +\t(\n>        +\t\tcd repo &&\n>       -+\t\tgit checkout -b unmerged origin/next --track &&\n>       ++\t\tgit checkout -b unmerged --track origin/next &&\n>        +\t\tgit commit --allow-empty -m \"unmerged work\" &&\n>       -+\t\tgit checkout -b tracks-other other/main --track &&\n>       ++\t\tgit checkout -b tracks-other --track other/main &&\n>        +\t\tsha=$(git rev-parse --short merged) &&\n>        +\n>        +\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t(\n>        +\t\tcd repo &&\n>        +\t\tgit config remote.pushDefault fork &&\n>       -+\t\tgit checkout -b local-to-delete main --track &&\n>       -+\t\tgit update-ref refs/remotes/origin/topic refs/remotes/origin/next &&\n>       -+\t\tgit branch --set-upstream-to=origin/topic upstream-gone &&\n>       -+\t\tgit update-ref -d refs/remotes/origin/topic &&\n>       -+\t\tgit checkout -b tracks-other other/main --track &&\n>       ++\t\tgit checkout -b local-to-delete --track main &&\n>       ++\t\tgit config branch.upstream-gone.merge refs/heads/topic &&\n>       ++\t\tgit checkout -b tracks-other --track other/main &&\n>        +\t\tgit checkout checked-out &&\n>        +\n>       -+\t\tgit branch --delete-merged origin/* \\\n>       -+\t\t\t--delete-merged main &&\n>       ++\t\tgit branch --delete-merged origin/* --delete-merged main &&\n>        +\n>        +\t\tcheck_branches <<-\\EOF\n>        +\t\tchecked-out\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t)\n>        +'\n>        +\n>       ++test_expect_success '--delete-merged maps push refspecs to upstreams' '\n>       ++\tsetup_repo_for_delete_merged &&\n>       ++\t(\n>       ++\t\tcd repo &&\n>       ++\t\tgit checkout -b topic &&\n>       ++\t\tgit config remote.origin.push \"refs/heads/topic:refs/heads/published\" &&\n>       ++\t\tgit push origin &&\n>       ++\t\tgit branch --set-upstream-to=origin/published topic &&\n>       ++\t\tgit checkout -b other-topic --track origin/published &&\n>       ++\t\tgit checkout --detach &&\n>       ++\n>       ++\t\tgit branch --delete-merged origin/published &&\n>       ++\n>       ++\t\tcheck_branches <<-\\EOF\n>       ++\t\tmain\n>       ++\t\ttopic\n>       ++\t\tEOF\n>       ++\t)\n>       ++'\n>       ++\n>        +test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n>        +\tsetup_repo_for_delete_merged &&\n>        +\tcreate_merged_branch feature &&\n>        +\t(\n>        +\t\tcd repo &&\n>       -+\t\tgit checkout -b topic feature --track &&\n>       ++\t\tgit checkout -b topic --track feature &&\n>        +\t\tgit commit --allow-empty -m \"topic work\" &&\n>        +\n>        +\t\tgit branch --delete-merged origin/next 2>err &&\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\t)\n>        +'\n>        +\n>       -+test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '\n>       ++test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '\n>        +\tsetup_repo_for_delete_merged &&\n>        +\t(\n>        +\t\tcd repo &&\n>       -+\t\tgit config remote.pushDefault fork &&\n>       -+\t\tgit branch lower origin/next --track &&\n>       -+\t\tgit branch mid lower --track &&\n>       -+\t\tgit checkout -b tip mid --track &&\n>       ++\t\tgit branch --track lower origin/next &&\n>       ++\t\tgit branch --track mid lower &&\n>       ++\t\tgit checkout -b tip --track mid &&\n>        +\t\tgit commit --allow-empty -m \"tip work\" &&\n>       ++\t\tsha=$(git rev-parse --short lower) &&\n>        +\n>       -+\t\tgit branch --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged lower >actual 2>&1 &&\n>       -+\t\ttest_must_be_empty actual &&\n>       ++\t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n>       ++\t\techo \"Deleted branch lower (was $sha).\" >expect &&\n>       ++\t\ttest_cmp expect actual &&\n>        +\n>        +\t\tcheck_branches <<-\\EOF &&\n>       -+\t\tlower\n>        +\t\tmain\n>        +\t\tmid\n>        +\t\ttip\n>        +\t\tEOF\n>        +\n>       -+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n>       ++\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n>        +\t\tcat >expect <<-\\EOF &&\n>       -+\t\tbranch.lower.remote origin\n>       -+\t\tbranch.lower.merge refs/heads/next\n>       -+\t\tbranch.mid.remote .\n>       -+\t\tbranch.mid.merge refs/heads/lower\n>        +\t\tbranch.tip.remote .\n>        +\t\tbranch.tip.merge refs/heads/mid\n>        +\t\tEOF\n>       @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n>        +\tsetup_repo_for_delete_merged &&\n>        +\t(\n>        +\t\tcd repo &&\n>       -+\t\tgit branch c-lower origin/next --track &&\n>       -+\t\tgit branch b-mid c-lower --track &&\n>       -+\t\tgit checkout -b a-tip b-mid --track &&\n>       ++\t\tgit branch --track c-lower origin/next &&\n>       ++\t\tgit branch --track b-mid c-lower &&\n>       ++\t\tgit checkout -b a-tip --track b-mid &&\n>        +\t\tgit commit --allow-empty -m \"tip work\" &&\n>        +\n>       -+\t\tgit branch --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged \"c-*\" &&\n>       ++\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" &&\n>        +\n>        +\t\tcheck_branches <<-\\EOF &&\n>        +\t\ta-tip\n>        +\t\tb-mid\n>       -+\t\tc-lower\n>        +\t\tmain\n>        +\t\tEOF\n>        +\n>       -+\t\tgit branch --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n>       ++\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" >actual 2>&1 &&\n>        +\t\ttest_must_be_empty actual &&\n>        +\n>        +\t\tcheck_branches <<-\\EOF\n>        +\t\ta-tip\n>        +\t\tb-mid\n>       -+\t\tc-lower\n>        +\t\tmain\n>        +\t\tEOF\n>        +\t)\n>   6:  6494be7c75 ! 6:  aa845096d8 branch: add branch.<name>.deleteMerged opt-out\n>       @@ Documentation/config/branch.adoc: for details).\n>        \n>         ## Documentation/git-branch.adoc ##\n>        @@ Documentation/git-branch.adoc: A branch is not deleted when:\n>       - +\n>       - --\n>       - * its configured upstream ref no longer exists,\n>       --* it is checked out in any worktree, or\n>       -+* it is checked out in any worktree,\n>       - * pushing it by name to the remote configured by\n>       -   `branch.<name>.remote` would update its upstream, so it cannot be\n>       -   distinguished from a branch that just looks \"fully merged\" right\n>       --  after a pull.\n>       -+  after a pull, or\n>       +   distinguished from a branch that just looks fully merged right\n>       +   after a pull; this is determined by the remote's configured push and\n>       +   fetch refspecs,\n>       +-* it is the local upstream of a branch that is not being deleted.\n>       ++* it is the local upstream of a branch that is not being deleted, or\n>        +* `branch.<name>.deleteMerged` is set to `false`.\n>         --\n>         +\n>       - A branch whose work has not yet been merged into its upstream is\n>       + When such a local upstream branch has its own upstream deleted by the\n>        \n>         ## builtin/branch.c ##\n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       - \tstruct ref_array candidates = { 0 };\n>         \tstruct strset deletable_branch_names = STRSET_INIT;\n>       + \tstruct strset protected_branch_names = STRSET_INIT;\n>         \tstruct strvec branches_to_delete = STRVEC_INIT;\n>        +\tstruct strbuf key = STRBUF_INIT;\n>         \tstruct hashmap_iter iter;\n>       @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstrea\n>         \t}\n>         \n>        @@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       - \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n>       - \t\t\t\t      flags);\n>       + \t\tclear_deleted_upstreams(&protected_branch_names,\n>       + \t\t\t\t\t&deletable_branch_names);\n>         \n>        +\tstrbuf_release(&key);\n>         \tstrvec_clear(&branches_to_delete);\n>       + \tstrset_clear(&protected_branch_names);\n>         \tstrset_clear(&deletable_branch_names);\n>       - \tref_array_clear(&candidates);\n>        \n>         ## t/t3200-branch.sh ##\n>        @@ t/t3200-branch.sh: test_expect_success '--delete-merged requires a value' '\n>   7:  7102c931e2 ! 7:  96ebfd6ec6 branch: add --dry-run for --delete-merged\n>       @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>\n>         git branch (-c|-C) [<old-branch>] <new-branch>\n>         git branch (-d|-D) [-r] <branch-name>...\n>         git branch --edit-description [<branch-name>]\n>       --git branch (--delete-merged <branch>)... [<pattern>...]\n>       -+git branch [--dry-run] (--delete-merged <branch>)... [<pattern>...]\n>       +-git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n>       ++git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]\n>         \n>         DESCRIPTION\n>         -----------\n>       -@@ Documentation/git-branch.adoc: A branch that a surviving branch depends on through a chain of local\n>       - upstreams is kept, so a branch is never deleted out from under stacked\n>       - work.\n>       +@@ Documentation/git-branch.adoc: A branch whose work has not yet been merged into its upstream is\n>       + silently skipped. Delete it with `git branch -D` if you want to\n>       + remove it anyway.\n>         \n>        +`--dry-run`::\n>        +\tWith `--delete-merged`, print which branches would be\n>       @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int ki\n>         \t\t\tchar *refname = name + branch_name_pos;\n>         \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n>         \t\t\t\tprintf(remote_branch\n>       +@@ builtin/branch.c: static int delete_merged_branches(const struct strvec *upstreams,\n>       + \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n>       + \t\t\t\t      flags);\n>       +\n>       +-\tif (!ret)\n>       ++\tif (!ret && !(flags & DELETE_BRANCH_DRY_RUN))\n>       + \t\tclear_deleted_upstreams(&protected_branch_names,\n>       + \t\t\t\t\t&deletable_branch_names);\n>       +\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tint delete = 0, rename = 0, copy = 0, list = 0,\n>         \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>         \tint noncreate_actions = 0;\n>         \t/* possible options */\n>        @@ builtin/branch.c: int cmd_branch(int argc,\n>       - \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"branch\"),\n>       - \t\t\tN_(\"delete merged branches whose upstream matches <branch> (repeatable)\"),\n>       + \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n>       + \t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n>         \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n>        +\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n>        +\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n>       @@ builtin/branch.c: int cmd_branch(int argc,\n>        \n>         ## t/t3200-branch.sh ##\n>        @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected merged branches' '\n>       - \t\tgit checkout -b tracks-other other/main --track &&\n>       + \t\tgit checkout -b tracks-other --track other/main &&\n>         \t\tsha=$(git rev-parse --short merged) &&\n>         \n>        +\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me\n>         \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n>         \t\ttest_cmp expect actual &&\n>        @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n>       - \t\tgit checkout -b topic feature --track &&\n>       + \t\tgit checkout -b topic --track feature &&\n>         \t\tgit commit --allow-empty -m \"topic work\" &&\n>         \n>        -\t\tgit branch --delete-merged origin/next 2>err &&\n>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream of a\n>         \t\tcheck_branches <<-\\EOF &&\n>         \t\tfeature\n>         \t\tmain\n>       -@@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain of a surviving bra\n>       - \t\tgit checkout -b tip mid --track &&\n>       +@@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstream of a protected\n>         \t\tgit commit --allow-empty -m \"tip work\" &&\n>       + \t\tsha=$(git rev-parse --short lower) &&\n>         \n>       -+\t\tgit branch --dry-run --delete-merged origin/next \\\n>       -+\t\t\t--delete-merged lower >actual 2>&1 &&\n>       -+\t\ttest_must_be_empty actual &&\n>       ++\t\tgit branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n>       ++\t\techo \"Would delete branch lower (was $sha).\" >expect &&\n>       ++\t\ttest_cmp expect actual &&\n>        +\n>        +\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n>        +\t\tcat >expect <<-\\EOF &&\n>       @@ t/t3200-branch.sh: test_expect_success '--delete-merged keeps the upstream chain\n>        +\t\tEOF\n>        +\t\ttest_cmp expect actual &&\n>        +\n>       - \t\tgit branch --delete-merged origin/next \\\n>       - \t\t\t--delete-merged lower >actual 2>&1 &&\n>       - \t\ttest_must_be_empty actual &&\n>       + \t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n>       + \t\techo \"Deleted branch lower (was $sha).\" >expect &&\n>       + \t\ttest_cmp expect actual &&\n>        @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n>         \t)\n>         '\n> \n\n"},{"id":"549612","messageId":"CAHwyqnX37oJU8797XTZwzV5-mj9pOgj+AsnO9+UpS4QGu5Nyjg@mail.gmail.com","threadId":"65574","inReplyTo":"0ea7bbec-3381-4bc6-99a9-2c3b5d1bfebd@gmail.com","subject":"Re: [PATCH v24 0/7] branch: delete-merged","fromName":"Harald Nordgren","fromEmail":"haraldnordgren@gmail.com","sentAt":"2026-08-04T20:33:41Z","receivedAt":"2026-08-04T20:34:19Z","isPatch":true,"body":"Thanks!\n\n\nHarald\n"},{"id":"549619","messageId":"xmqqcxvxtx5n.fsf@gitster.g","threadId":"65574","inReplyTo":"0ea7bbec-3381-4bc6-99a9-2c3b5d1bfebd@gmail.com","subject":"Re: [PATCH v24 0/7] branch: delete-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-04T21:04:36Z","receivedAt":"2026-08-04T21:04:39Z","isPatch":true,"body":"Phillip Wood <phillip.wood123@gmail.com> writes:\n\n> As far as I can see, from here ...\n>\n>>       @@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const ch\n>...\n>>       ++\tstrbuf_release(&key);\n>>        +}\n>\n> ... to here pretty much takes us back to V19 with the difference that we \n> only prune protected branches from the deleteable set after we've \n> visited all the branches - looks good.\n>\n>>        +static int branch_pushes_to_upstream(struct branch *branch,\n>>        +\t\t\t\t     const char *upstream)\n>>        +{\n>>        +\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n>>       ++\tchar *push_refname = NULL;\n>>        +\tchar *tracking = NULL;\n>>        +\tint ret = 0;\n>>        +\n>>       -+\tif (remote)\n>>       -+\t\ttracking = apply_refspecs(&remote->fetch, branch->refname);\n>>       ++\tif (!remote)\n>>       ++\t\treturn 0;\n>>       ++\tif (remote->push.nr)\n>>       ++\t\tpush_refname = apply_refspecs(&remote->push, branch->refname);\n>>       ++\telse\n>>       ++\t\tpush_refname = xstrdup(branch->refname);\n>>       ++\tif (push_refname)\n>>       ++\t\ttracking = apply_refspecs(&remote->fetch, push_refname);\n>>        +\tif (tracking && !strcmp(tracking, upstream))\n>>        +\t\tret = 1;\n>>       ++\tfree(push_refname);>        +\tfree(tracking);\n>>        +\treturn ret;\n>>        +}\n>\n> This looks good now - we map the branch name with the push refspec if \n> there is one and then with the fetch refspec.\n>\n> The test changes below look good as well.\n\nOK.\n\nI'd mark the topic as \"expecting a (hopefully small and final)\nreroll\", perhaps.\n\nThanks.\n"},{"id":"549704","messageId":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.git.git.1777671337839.gitgitgadget@gmail.com","subject":"[PATCH v25 0/7] branch: delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:30Z","receivedAt":"2026-08-05T14:24:44Z","isPatch":true,"body":"Delete branches that have already been merged on upstream.\n\nChanges in v25:\n\n * Wrap long lines in tests.\n\nChanges in v24:\n\n * Map branches through the configured push and fetch refspecs before\n   deciding whether pushing would update their upstream.\n * Protect only the direct upstream of surviving work, delete deeper merged\n   bases and clear stale upstream configuration.\n * Clarify that --delete-merged takes upstream patterns in docs.\n\nChanges in v23:\n\n * Use a loop-local size_t iterator for --forked patterns.\n * Simplify upstream-prefix stripping into an explicit fallback.\n\nChanges in v22:\n\n * Loop variable int when appropriate to avoid typecast.\n\nChanges in v21:\n\n * Change loop variables to prevent CI timeout.\n\nChanges in v20:\n\n * Protect branches transitively required by a surviving local upstream\n   stack. Traverse upstream chains once and defer delete-set mutation until\n   traversal completes.\n * Make stacked-branch handling independent of ref iteration order and\n   update the documentation accordingly.\n * Clarify variable names with regards to branch names (short) to reduce\n   confusion.\n\nChanges in v19:\n\n * Fix bug where dry-run would still remove config, added test coverage.\n * Redesigned --delete-merged as a repeatable upstream selector with\n   optional positional patterns limiting deletion scope.\n * Protect same-name upstream branches independently of push-default\n   configuration.\n * Simplified flags handling where local caching became complicated when\n   mutating values.\n * Clarified assertions in tests.\n\nChanges in v18:\n\n * Instead of keeping the whole chain of upstream branches, keep only the\n   ones an unmerged branch still needs. When a kept (merged) branch in turn\n   tracks a branch that is being deleted, clear its now-stale upstream\n   config.\n * Rework spare_stacked_bases() to record the kept bases and, in a second\n   pass, clear the upstream of any whose own base is going away. Build the\n   to-delete list with strset_for_each_entry() instead of re-walking the\n   candidate array.\n\nChanges in v17:\n\n * Keep a merged branch when another surviving branch still tracks it as its\n   upstream, so --delete-merged no longer deletes a branch out from under\n   one stacked on top of it.\n * Move the --dry-run and branch.<name>.deleteMerged opt-out fully into\n   their own commits.\n\nChanges in v16:\n\n * Convert delete_merged_branches() to take an unsigned int flags argument\n   instead of separate quiet/dry_run booleans, matching delete_branches()\n * Reuse the strbuf across the skip-config loop (strbuf_reset per iteration,\n   single strbuf_release after) instead of allocating and freeing it each\n   time\n * Rewrite the --delete-merged tests as integration tests: branches that\n   land commits upstream, with deletion and the checked-out, upstream-gone,\n   and push-equals-upstream safety cases exercised together in one run and\n   output asserted via test_cmp\n * Collapse the many per-aspect test repos into a single reused repo set up\n   by a setup_repo_for_delete_merged helper, and rename helpers off the old\n   pm_/prune naming\n * Nest single-repo setup sequences in ( cd ... ) subshells instead of\n   prefixing every command with -C\n\nChanges in v15:\n\n * Renamed --prune-merged to --delete-merged throughout. Not necessarily\n   final, but something to advance the discussion.\n * --delete-merged now silently skips not-yet-merged branches instead of\n   warning.\n * Initialized the delete_branches() flag locals where declared. Only force\n   stays deferred.\n * delete_branches()/check_branch_commit() doc and code cleanups: redundant\n   branch NULL checks dropped, ref_array candidates = { 0 }, a BUG() for the\n   unreachable non-branch ref, and reworked --delete-merged doc wording.\n * Broadened the --forked tests (local commits for realism, remote add -f,\n   --forked coverage), renamed the misleading trunk fixture, and replaced\n   the misnamed detached branch with git checkout --detach.\n\nChanges in v14:\n\n * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the\n   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that\n   check_branch_commit() reads, so it wrongly ran the merge check.\n * Made flags the single source of truth in delete_branches() so the bit and\n   the derived locals can't disagree.\n * Works locally, but GitHub CI has problems that are there for other\n   branches too, hopefully not related\n   (https://github.com/git/git/pull/2285).\n\nChanges in v13:\n\n * Reworked --forked into a real ref-filter applied in apply_ref_filter()\n   instead of a post-pass, so non-matching branches are never allocated.\n * Match exact --forked patterns on full refnames (only globs use the\n   abbreviated upstream), and dropped the old helper machinery, forward\n   declaration, and string_list in favor of a strvec.\n * Replaced the boolean parameters of\n   delete_branches()/check_branch_commit() with a single unsigned int flags.\n * --prune-merged now collects candidates via filter_refs() rather than its\n   own branch walk.\n * --prune-merged now takes its patterns as positional arguments (e.g. git\n   branch --prune-merged origin/main 'feature*') instead of repeating the\n   option.\n\nChanges in v12:\n\n * Reworked --forked from a standalone action into a --list-mode filter.\n * Switched --forked and --prune-merged to repeatable OPT_STRING_LIST\n   options.\n * Dropped the bare-remote-name resolution for --forked, the argument is now\n   a ref or a glob.\n\nChanges in v11:\n\n * The flags now take a branch, not a remote. --forked and --prune-merged\n   accept a literal upstream short name like origin/main or a wildmatch\n   pattern like origin/. The old --all-remotes flag is gone, since origin/\n   covers that case.\n * The prune guard now compares @{push} against @{upstream}. A branch is\n   spared when these are equal. That is the trunk like case, such as local\n   main tracking and pushing to origin/main, where \"fully merged to\n   upstream\" cannot be told apart from \"just pulled\". Only branches that\n   push somewhere other than their upstream, typically fork based topics,\n   are candidates. The earlier /HEAD by name guard that the reviewer\n   rejected is gone.\n * New --dry-run for --prune-merged.\n\nChanges in v10:\n\n * --forked / --prune-merged now take a branch glob instead of a remote name\n   — origin, origin/*, origin/release-- all work. This replaces the\n   remote-only form and subsumes the old --all-remotes flag, which has been\n   dropped.\n * New --dry-run for --prune-merged.\n\nChanges in v9:\n\n * --force no longer has special meaning with --prune-merged; reachability\n   is always enforced. Use git branch -D to delete an unmerged branch.\n   Matches how git branch's other read/safe actions treat --force.\n * Synopsis drops [-f]; \"not fully merged\" hint points at git branch -D.\n * Dropped the --prune-merged --force tests.\n\nChanges in v8:\n\n * Delete only when the branch's work is actually reachable from its\n   upstream\n * Skip branches whose upstream is gone (even with --force)\n * Simplified the internal safety flag to live in one place\n\nChanges in v7:\n\n * --prune-merged now checks if a branch is merged into its own upstream\n   first. If the upstream is gone, it checks against the remote's default\n   branch instead. If neither exists, the branch is refused (use --force to\n   delete anyway).\n\nChanges in v6:\n\n * --prune-merged now measures merged-ness against the remote's default\n   branch instead of the candidate's upstream — so the decision no longer\n   depends on which branch happens to be checked out locally.\n * delete_branches() / check_branch_commit() gained a per-candidate override\n   that lets a caller substitute a different \"what counts as merged\"\n   reference (or skip the check). branch -d callers pass NULL and keep their\n   existing semantics.\n * prune_merged_branches() resolves each candidate's push-remote HEAD and\n   threads it through, so --prune-merged --all-remotes measures each\n   candidate against its own remote rather than a single global reference.\n\nChanges in v5:\n\n * Drop commit 'fetch: add --prune-merged'\n\nChanges in v4:\n\n * Resolve each remote's HEAD and collect the targets into a\n   protected_default_refs set in collect_forked_set.\n * In prune_merged_branches, skip a candidate when its upstream is a\n   protected default ref and the local branch name matches the default\n   branch's leaf name (so a local main tracking origin/main is spared, but a\n   renamed trunk tracking origin/main is not).\n * Also skip when the candidate's push ref points at a protected default\n   ref, so a topic branch configured to push to origin/main is never pruned.\n * Tests: spare the local default branch; only protect by matching leaf name\n   (not by upstream alone); spare a branch whose push ref is the remote\n   default.\n\nChanges in v3:\n\n * s/remote-tracking refs/remote-tracking branches/g\n\nChanges in v2:\n\n * The whole feature moved out of git fetch and into git branch. git fetch\n   --prune-merged now just calls git branch --prune-merged after fetching.\n * The fetch.pruneLocalBranches and remote..pruneLocalBranches config\n   options are gone, replaced by per-branch opt-out via branch..pruneMerged.\n * New git branch --forked lists local branches whose upstream lives on the\n   given remote (read-only building block).\n * New git branch --prune-merged deletes those branches, but only if their\n   tip is reachable from the upstream tracking ref; --force skips that\n   safety check.\n * New git branch --all-remotes lets --forked/--prune-merged operate across\n   every configured remote at once.\n * The currently checked-out branch in any worktree is always preserved.\n * branch..pruneMerged=false lets you exempt a branch (e.g. a long-running\n   topic branch) even with --force; doesn't affect explicit git branch -d.\n * delete_branches() got a warn_only mode so bulk deletion prints a one-line\n   warning per skipped branch instead of the noisy four-line hint that git\n   branch -d shows.\n * New section in git-branch docs; git-fetch docs trimmed to just mention\n   --prune-merged.\n * New tests in t3200-branch.sh for the new branch flags; t5510-fetch.sh\n   shrunk since most logic moved.\n\nHarald Nordgren (7):\n  branch: add --forked filter for --list mode\n  branch: convert delete_branches() to a flags argument\n  branch: let delete_branches skip unmerged branches on bulk refusal\n  branch: prepare delete_branches for a bulk caller\n  branch: add --delete-merged <pattern>\n  branch: add branch.<name>.deleteMerged opt-out\n  branch: add --dry-run for --delete-merged\n\n Documentation/config/branch.adoc |   7 +\n Documentation/git-branch.adoc    |  52 +++-\n builtin/branch.c                 | 312 ++++++++++++++++++++---\n ref-filter.c                     |  70 +++++\n ref-filter.h                     |  10 +\n t/t3200-branch.sh                | 421 +++++++++++++++++++++++++++++++\n 6 files changed, 841 insertions(+), 31 deletions(-)\n\n\nbase-commit: 5b2471720c93ee30e5764a19f3d3b3ae9ec9712a\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v25\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v25\nPull-Request: https://github.com/git/git/pull/2285\n\nRange-diff vs v24:\n\n 1:  59157142b3 ! 1:  2f8953b339 branch: add --forked filter for --list mode\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n     -+\tgit -C forked branch --forked origin/one --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked origin/one \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--forked <glob> filters by wildmatch' '\n     -+\tgit -C forked branch --forked \"origin/*\" --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"origin/*\" \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n      +\tlocal-two\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked <local-branch> matches branches with local upstream' '\n     -+\tgit -C forked branch --forked local-base --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked local-base \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\techo local-onbase >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--forked can be repeated to widen the filter' '\n     -+\tgit -C forked branch --forked origin/one --forked other/foreign --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked origin/one \\\n     ++\t\t--forked other/foreign \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-one\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked combines literal and glob arguments' '\n     -+\tgit -C forked branch --forked local-base --forked \"other/*\" --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked local-base \\\n     ++\t\t--forked \"other/*\" \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-onbase\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n     -+\tgit -C forked branch --forked \"*/*\" --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"*/*\" \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-foreign\n      +\tlocal-one\n     @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '\n      +'\n      +\n      +test_expect_success '--forked composes with --no-merged' '\n     -+\tgit -C forked branch --forked \"origin/*\" --no-merged origin/one --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"origin/*\" \\\n     ++\t\t--no-merged origin/one \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\techo local-one >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n     -+\tgit -C forked branch --forked origin --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked origin \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\techo main >expect &&\n      +\ttest_cmp expect actual\n      +'\n      +\n      +test_expect_success '--forked narrows a <pattern> argument' '\n     -+\tgit -C forked branch --forked \"origin/*\" \"local-*\" --format=\"%(refname:short)\" >actual &&\n     ++\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n     ++\t\t--format=\"%(refname:short)\" >actual &&\n      +\tcat >expect <<-\\EOF &&\n      +\tlocal-one\n      +\tlocal-two\n 2:  5d310da9aa = 2:  2e80fc3791 branch: convert delete_branches() to a flags argument\n 3:  263e90f6ed = 3:  4ab81f0b0a branch: let delete_branches skip unmerged branches on bulk refusal\n 4:  22e31fd35c = 4:  83cc3fd6a5 branch: prepare delete_branches for a bulk caller\n 5:  d9f93e66c9 ! 5:  a5ef60a394 branch: add --delete-merged <pattern>\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t(\n      +\t\tcd repo &&\n      +\t\tgit checkout -b topic &&\n     -+\t\tgit config remote.origin.push \"refs/heads/topic:refs/heads/published\" &&\n     ++\t\tgit config remote.origin.push \\\n     ++\t\t\t\"refs/heads/topic:refs/heads/published\" &&\n      +\t\tgit push origin &&\n      +\t\tgit branch --set-upstream-to=origin/published topic &&\n      +\t\tgit checkout -b other-topic --track origin/published &&\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t\ttopic\n      +\t\tEOF\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(feature|topic)\\\\.(merge|remote)\" >actual &&\n     ++\t\tpattern=\"branch\\\\.(feature|topic)\\\\.(merge|remote)\" &&\n     ++\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n      +\t\tbranch.feature.remote origin\n      +\t\tbranch.feature.merge refs/heads/next\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t\tgit commit --allow-empty -m \"tip work\" &&\n      +\t\tsha=$(git rev-parse --short lower) &&\n      +\n     -+\t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     ++\t\tgit branch --delete-merged origin/next \\\n     ++\t\t\t--delete-merged lower >actual 2>&1 &&\n      +\t\techo \"Deleted branch lower (was $sha).\" >expect &&\n      +\t\ttest_cmp expect actual &&\n      +\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t\ttip\n      +\t\tEOF\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tpattern=\"branch\\\\.(mid|tip)\\\\.(merge|remote)\" &&\n     ++\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n      +\t\tbranch.tip.remote .\n      +\t\tbranch.tip.merge refs/heads/mid\n     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '\n      +\t\tmain\n      +\t\tEOF\n      +\n     -+\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" >actual 2>&1 &&\n     ++\t\tgit branch --delete-merged origin/next \\\n     ++\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n      +\t\ttest_must_be_empty actual &&\n      +\n      +\t\tcheck_branches <<-\\EOF\n 6:  aa845096d8 = 6:  c2d1fdebe8 branch: add branch.<name>.deleteMerged opt-out\n 7:  96ebfd6ec6 ! 7:  5a936bcb93 branch: add --dry-run for --delete-merged\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged deletes only selected me\n       \t\tgit checkout -b tracks-other --track other/main &&\n       \t\tsha=$(git rev-parse --short merged) &&\n       \n     -+\t\tgit branch --dry-run --delete-merged origin/next merged >actual 2>&1 &&\n     ++\t\tgit branch --dry-run --delete-merged origin/next merged \\\n     ++\t\t\t>actual 2>&1 &&\n      +\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n      +\t\ttest_cmp expect actual &&\n      +\t\tgit rev-parse --verify refs/heads/merged &&\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr\n       \t\tgit commit --allow-empty -m \"tip work\" &&\n       \t\tsha=$(git rev-parse --short lower) &&\n       \n     -+\t\tgit branch --dry-run --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     ++\t\tgit branch --dry-run --delete-merged origin/next \\\n     ++\t\t\t--delete-merged lower >actual 2>&1 &&\n      +\t\techo \"Would delete branch lower (was $sha).\" >expect &&\n      +\t\ttest_cmp expect actual &&\n      +\n     -+\t\tgit config --local --get-regexp \"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" >actual &&\n     ++\t\tpattern=\"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" &&\n     ++\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n      +\t\tcat >expect <<-\\EOF &&\n      +\t\tbranch.lower.remote origin\n      +\t\tbranch.lower.merge refs/heads/next\n     @@ t/t3200-branch.sh: test_expect_success '--delete-merged clears the deleted upstr\n      +\t\tEOF\n      +\t\ttest_cmp expect actual &&\n      +\n     - \t\tgit branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&\n     + \t\tgit branch --delete-merged origin/next \\\n     + \t\t\t--delete-merged lower >actual 2>&1 &&\n       \t\techo \"Deleted branch lower (was $sha).\" >expect &&\n     - \t\ttest_cmp expect actual &&\n      @@ t/t3200-branch.sh: test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n       \t)\n       '\n\n-- \ngitgitgadget\n"},{"id":"549707","messageId":"2f8953b339df864eff5c76ece920aadeb00309f0.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 1/7] branch: add --forked filter for --list mode","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:31Z","receivedAt":"2026-08-05T14:24:46Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a --forked option to \"git branch\" list mode that lists only\nbranches whose configured upstream matches <branch>. The argument\ncan be a ref (e.g. \"origin/main\", \"master\"), a remote name like\n\"origin\" for the branch its origin/HEAD points at, or a shell glob\n(e.g. \"origin/*\"), and may be repeated to widen the filter.\n\nIt is an ordinary list filter, so it combines with the others:\n\n    git branch --merged origin/main --forked 'origin/*'\n\nlists branches forked from origin that are already merged into\norigin/main, and --no-merged inverts the question.\n\nThis is the building block for --delete-merged, which deletes the\nlisted branches once they have landed on their upstream.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  12 +++-\n builtin/branch.c              |  18 ++++-\n ref-filter.c                  |  70 +++++++++++++++++++\n ref-filter.h                  |  10 +++\n t/t3200-branch.sh             | 127 ++++++++++++++++++++++++++++++++++\n 5 files changed, 234 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex c0afddc424..b0d66a6deb 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]\n \t   [--column[=<options>] | --no-column] [--sort=<key>]\n \t   [--merged [<commit>]] [--no-merged [<commit>]]\n \t   [--contains [<commit>]] [--no-contains [<commit>]]\n+\t   [(--forked <branch>)...]\n \t   [--points-at <object>] [--format=<format>]\n \t   [(-r|--remotes) | (-a|--all)]\n \t   [--list] [<pattern>...]\n@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are\n reachable from the named commit) will be listed.  With `--no-merged` only\n branches not merged into the named commit will be listed.  If the _<commit>_\n argument is missing it defaults to `HEAD` (i.e. the tip of the current\n-branch).\n+branch).  With `--forked`, only branches whose configured upstream matches\n+the given branch or pattern will be listed.\n \n The command's second form creates a new branch head named _<branch-name>_\n which points to the current `HEAD`, or _<start-point>_ if given. As a\n@@ -311,6 +313,14 @@ superproject's \"origin/main\", but tracks the submodule's \"origin/main\".\n \tOnly list branches whose tips are not reachable from\n \t_<commit>_ (`HEAD` if not specified). Implies `--list`.\n \n+`--forked <branch>`::\n+\tOnly list branches whose configured upstream matches\n+\t_<branch>_. The argument can be a ref (e.g. `origin/main`,\n+\t`master`), a remote name like `origin` for the branch its\n+\t`origin/HEAD` points at, or a shell-style glob (e.g.\n+\t`'origin/*'`). The option can be repeated to widen the\n+\tfilter. Implies `--list`.\n+\n `--points-at <object>`::\n \tOnly list branches of _<object>_.\n \ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 031a4a9d05..1ab4356188 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -30,7 +30,7 @@\n #include \"commit-reach.h\"\n \n static const char * const builtin_branch_usage[] = {\n-\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged]\"),\n+\tN_(\"git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]\"),\n \tN_(\"git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]\"),\n \tN_(\"git branch [<options>] [-l] [<pattern>...]\"),\n \tN_(\"git branch [<options>] [-r] (-d | -D) <branch-name>...\"),\n@@ -674,6 +674,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int\n \tfree_worktrees(worktrees);\n }\n \n+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)\n+{\n+\tstruct ref_filter *filter = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\tif (ref_filter_forked_add(filter, arg) < 0)\n+\t\tdie(_(\"'%s' is not a valid branch or pattern\"), arg);\n+\treturn 0;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -794,6 +804,9 @@ int cmd_branch(int argc,\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n+\t\tOPT_CALLBACK_F(0, \"forked\", &filter, N_(\"branch\"),\n+\t\t\tN_(\"print only branches whose upstream matches <branch> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_forked),\n \t\tOPT_COLUMN(0, \"column\", &colopts, N_(\"list branches in columns\")),\n \t\tOPT_REF_SORT(&sorting_options),\n \t\tOPT_CALLBACK(0, \"points-at\", &filter.points_at, N_(\"object\"),\n@@ -839,7 +852,8 @@ int cmd_branch(int argc,\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n-\t    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)\n+\t    filter.reachable_from || filter.unreachable_from ||\n+\t    filter.points_at.nr || filter.forked.nr)\n \t\tlist = 1;\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\ndiff --git a/ref-filter.c b/ref-filter.c\nindex 29aca08ce7..bdf54f6f59 100644\n--- a/ref-filter.c\n+++ b/ref-filter.c\n@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)\n \treturn match_pattern(filter->exclude.v, refname, filter->ignore_case);\n }\n \n+static const char *short_upstream_name(const char *full_ref)\n+{\n+\tconst char *short_name = full_ref;\n+\n+\tif (!skip_prefix(short_name, \"refs/heads/\", &short_name))\n+\t\tskip_prefix(short_name, \"refs/remotes/\", &short_name);\n+\treturn short_name;\n+}\n+\n+/*\n+ * Match the configured upstream of a branch against the registered\n+ * --forked patterns. Exact patterns are compared against the full\n+ * upstream refname so they are unambiguous; glob patterns are matched\n+ * against the abbreviated upstream so that a glob such as origin/...\n+ * works as typed.\n+ */\n+static int filter_forked_match(struct ref_filter *filter, const char *refname)\n+{\n+\tconst char *short_name;\n+\tstruct branch *branch;\n+\tconst char *upstream;\n+\n+\tif (!skip_prefix(refname, \"refs/heads/\", &short_name))\n+\t\treturn 0;\n+\tbranch = branch_get(short_name);\n+\tif (!branch)\n+\t\treturn 0;\n+\tupstream = branch_get_upstream(branch, NULL);\n+\tif (!upstream)\n+\t\treturn 0;\n+\n+\tfor (size_t i = 0; i < filter->forked.nr; i++) {\n+\t\tconst char *pattern = filter->forked.v[i];\n+\t\tif (has_glob_specials(pattern)) {\n+\t\t\tif (!wildmatch(pattern, short_upstream_name(upstream),\n+\t\t\t\t       WM_PATHNAME))\n+\t\t\t\treturn 1;\n+\t\t} else if (!strcmp(pattern, upstream)) {\n+\t\t\treturn 1;\n+\t\t}\n+\t}\n+\treturn 0;\n+}\n+\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)\n+{\n+\tstruct object_id oid;\n+\tchar *full_ref = NULL;\n+\n+\tif (has_glob_specials(arg)) {\n+\t\tstrvec_push(&filter->forked, arg);\n+\t\treturn 0;\n+\t}\n+\n+\tif (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,\n+\t\t\t  &full_ref, 0) == 1 &&\n+\t    (starts_with(full_ref, \"refs/heads/\") ||\n+\t     starts_with(full_ref, \"refs/remotes/\"))) {\n+\t\tstrvec_push(&filter->forked, full_ref);\n+\t\tfree(full_ref);\n+\t\treturn 0;\n+\t}\n+\tfree(full_ref);\n+\treturn -1;\n+}\n+\n /*\n  * We need to seek to the reference right after a given marker but excluding any\n  * matching references. So we seek to the lexicographically next reference.\n@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,\n \tif (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))\n \t\treturn NULL;\n \n+\tif (filter->forked.nr && !filter_forked_match(filter, ref->name))\n+\t\treturn NULL;\n+\n \t/*\n \t * A merge filter is applied on refs pointing to commits. Hence\n \t * obtain the commit using the 'oid' available and discard all\n@@ -3764,6 +3833,7 @@ void ref_filter_init(struct ref_filter *filter)\n void ref_filter_clear(struct ref_filter *filter)\n {\n \tstrvec_clear(&filter->exclude);\n+\tstrvec_clear(&filter->forked);\n \toid_array_clear(&filter->points_at);\n \tcommit_list_free(filter->with_commit);\n \tcommit_list_free(filter->no_commit);\ndiff --git a/ref-filter.h b/ref-filter.h\nindex 120221b47f..9361296e2a 100644\n--- a/ref-filter.h\n+++ b/ref-filter.h\n@@ -67,6 +67,7 @@ struct ref_filter {\n \tconst char **name_patterns;\n \tconst char *start_after;\n \tstruct strvec exclude;\n+\tstruct strvec forked;\n \tstruct oid_array points_at;\n \tstruct commit_list *with_commit;\n \tstruct commit_list *no_commit;\n@@ -110,6 +111,7 @@ struct ref_format {\n #define REF_FILTER_INIT { \\\n \t.points_at = OID_ARRAY_INIT, \\\n \t.exclude = STRVEC_INIT, \\\n+\t.forked = STRVEC_INIT, \\\n }\n #define REF_FORMAT_INIT {             \\\n \t.use_color = GIT_COLOR_UNKNOWN, \\\n@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);\n struct ref_sorting *ref_sorting_options(struct string_list *);\n /*  Function to parse --merged and --no-merged options */\n int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);\n+/*\n+ * Register a --forked <branch> pattern on the filter. The argument is\n+ * either a ref, which is resolved to its full refname, or a shell-style\n+ * glob. Branches are kept only when their configured upstream matches\n+ * one of the registered patterns. Returns -1 if the argument is not a\n+ * valid ref or pattern.\n+ */\n+int ref_filter_forked_add(struct ref_filter *filter, const char *arg);\n /*  Get the current HEAD's description */\n char *get_head_description(void);\n /*  Set up translated strings in the output. */\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 7f7807eb88..c2f3fd8fa8 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1757,4 +1757,131 @@ test_expect_success 'errors if given a bad branch name' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success '--forked: setup' '\n+\ttest_create_repo forked-upstream &&\n+\t(\n+\t\tcd forked-upstream &&\n+\t\ttest_commit base &&\n+\t\tgit branch one base &&\n+\t\tgit branch two base\n+\t) &&\n+\n+\ttest_create_repo forked-other &&\n+\t(\n+\t\tcd forked-other &&\n+\t\ttest_commit other-base &&\n+\t\tgit branch foreign other-base\n+\t) &&\n+\n+\tgit clone forked-upstream forked &&\n+\t(\n+\t\tcd forked &&\n+\t\tgit remote add -f other ../forked-other &&\n+\t\tgit branch local-base &&\n+\t\tgit branch --track local-one origin/one &&\n+\t\tgit branch --track local-two origin/two &&\n+\t\tgit branch --track local-foreign other/foreign &&\n+\t\tgit branch --track local-onbase local-base &&\n+\n+\t\tgit checkout local-one &&\n+\t\ttest_commit --no-tag local-one-work local-one.t &&\n+\t\tgit checkout local-foreign &&\n+\t\ttest_commit --no-tag local-foreign-work local-foreign.t\n+\t)\n+'\n+\n+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '\n+\tgit -C forked branch --forked origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <glob> filters by wildmatch' '\n+\tgit -C forked branch --forked \"origin/*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <local-branch> matches branches with local upstream' '\n+\tgit -C forked branch --forked local-base \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-onbase >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked can be repeated to widen the filter' '\n+\tgit -C forked branch --forked origin/one \\\n+\t\t--forked other/foreign \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked combines literal and glob arguments' '\n+\tgit -C forked branch --forked local-base \\\n+\t\t--forked \"other/*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-onbase\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked \"*/*\" covers every remote-tracking upstream' '\n+\tgit -C forked branch --forked \"*/*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-foreign\n+\tlocal-one\n+\tlocal-two\n+\tmain\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked composes with --no-merged' '\n+\tgit -C forked branch --forked \"origin/*\" \\\n+\t\t--no-merged origin/one \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo local-one >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '\n+\tgit -C forked branch --forked origin \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\techo main >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked narrows a <pattern> argument' '\n+\tgit -C forked branch --forked \"origin/*\" \"local-*\" \\\n+\t\t--format=\"%(refname:short)\" >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tlocal-one\n+\tlocal-two\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success '--forked rejects unknown branch/pattern' '\n+\ttest_must_fail git -C forked branch --forked nope 2>err &&\n+\ttest_grep \"not a valid branch or pattern\" err\n+'\n+\n+test_expect_success '--forked requires a value' '\n+\ttest_must_fail git -C forked branch --forked 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549706","messageId":"4ab81f0b0aad39d4e55fedba045d4182772624e2.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 3/7] branch: let delete_branches skip unmerged branches on bulk refusal","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:33Z","receivedAt":"2026-08-05T14:24:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nAdd a skip-unmerged mode to delete_branches() and check_branch_commit()\nso a bulk caller can silently skip branches that are not fully merged\nand carry on, rather than erroring with the \"use 'git branch -D'\"\nadvice that the plain \"git branch -d\" path emits. Existing callers are\nunaffected.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 15 ++++++++++-----\n 1 file changed, 10 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex db7cb01190..c44f710a48 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -192,6 +192,7 @@ static int branch_merged(int kind, const char *name,\n enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n+\tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -205,10 +206,13 @@ static int check_branch_commit(const char *branchname, const char *refname,\n \t}\n \tif (!(flags & DELETE_BRANCH_FORCE) &&\n \t    !branch_merged(kinds, branchname, rev, head_rev)) {\n-\t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n-\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n-\t\t\t\t  _(\"If you are sure you want to delete it, \"\n-\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED)) {\n+\t\t\terror(_(\"the branch '%s' is not fully merged\"),\n+\t\t\t      branchname);\n+\t\t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n+\t\t\t\t\t  _(\"If you are sure you want to delete it, \"\n+\t\t\t\t\t  \"run 'git branch -D %s'\"), branchname);\n+\t\t}\n \t\treturn -1;\n \t}\n \treturn 0;\n@@ -316,7 +320,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n \t\t\t\t\tflags)) {\n-\t\t\tret = 1;\n+\t\t\tif (!(flags & DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n-- \ngitgitgadget\n\n"},{"id":"549708","messageId":"2e80fc37919a6eb33ff8783dbcf2dededb3ed7b0.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 2/7] branch: convert delete_branches() to a flags argument","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:32Z","receivedAt":"2026-08-05T14:24:48Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\ndelete_branches() takes separate force and quiet parameters, while\ncheck_branch_commit() takes force. The next commits would grow this\ncollection further. Replace them with a single unsigned flags argument\nand an enum.\n\nTest the FORCE and QUIET bits directly from flags at each use site so\nthat mutating or forwarding flags cannot leave cached values stale.\n\nNo change in behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 40 ++++++++++++++++++++++++----------------\n 1 file changed, 24 insertions(+), 16 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 1ab4356188..db7cb01190 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -189,16 +189,22 @@ static int branch_merged(int kind, const char *name,\n \treturn merged;\n }\n \n+enum delete_branch_flags {\n+\tDELETE_BRANCH_FORCE = (1 << 0),\n+\tDELETE_BRANCH_QUIET = (1 << 1),\n+};\n+\n static int check_branch_commit(const char *branchname, const char *refname,\n \t\t\t       const struct object_id *oid, struct commit *head_rev,\n-\t\t\t       int kinds, int force)\n+\t\t\t       int kinds, unsigned int flags)\n {\n \tstruct commit *rev = lookup_commit_reference(the_repository, oid);\n-\tif (!force && !rev) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) && !rev) {\n \t\terror(_(\"couldn't look up commit object for '%s'\"), refname);\n \t\treturn -1;\n \t}\n-\tif (!force && !branch_merged(kinds, branchname, rev, head_rev)) {\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !branch_merged(kinds, branchname, rev, head_rev)) {\n \t\terror(_(\"the branch '%s' is not fully merged\"), branchname);\n \t\tadvise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,\n \t\t\t\t  _(\"If you are sure you want to delete it, \"\n@@ -217,8 +223,8 @@ static void delete_branch_config(const char *branchname)\n \tstrbuf_release(&buf);\n }\n \n-static int delete_branches(int argc, const char **argv, int force, int kinds,\n-\t\t\t   int quiet)\n+static int delete_branches(int argc, const char **argv, int kinds,\n+\t\t\t   unsigned int flags)\n {\n \tstruct commit *head_rev = NULL;\n \tstruct object_id oid;\n@@ -241,7 +247,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tremote_branch = 1;\n \t\tallowed_interpret = INTERPRET_BRANCH_REMOTE;\n \n-\t\tforce = 1;\n+\t\tflags |= DELETE_BRANCH_FORCE;\n \t\tbreak;\n \tcase FILTER_REFS_BRANCHES:\n \t\tfmt = \"refs/heads/%s\";\n@@ -252,12 +258,12 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!force)\n+\tif (!(flags & DELETE_BRANCH_FORCE))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n \t\tchar *target = NULL;\n-\t\tint flags = 0;\n+\t\tint ref_flags = 0;\n \n \t\tcopy_branchname(the_repository, &bname,\n \t\t\t\targv[i], allowed_interpret);\n@@ -280,7 +286,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t     RESOLVE_REF_READING\n \t\t\t\t\t     | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t     | RESOLVE_REF_ALLOW_BAD_NAME,\n-\t\t\t\t\t     &oid, &flags);\n+\t\t\t\t\t     &oid, &ref_flags);\n \t\tif (!target) {\n \t\t\tif (remote_branch) {\n \t\t\t\terror(_(\"remote-tracking branch '%s' not found\"), bname.buf);\n@@ -292,7 +298,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_NO_RECURSE\n \t\t\t\t\t\t\t\t\t   | RESOLVE_REF_ALLOW_BAD_NAME,\n \t\t\t\t\t\t\t\t\t   &oid,\n-\t\t\t\t\t\t\t\t\t   &flags);\n+\t\t\t\t\t\t\t\t\t   &ref_flags);\n \t\t\t\tFREE_AND_NULL(virtual_name);\n \n \t\t\t\tif (virtual_target)\n@@ -307,16 +313,16 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\t\tcontinue;\n \t\t}\n \n-\t\tif (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n+\t\tif (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&\n \t\t    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,\n-\t\t\t\t\tforce)) {\n+\t\t\t\t\tflags)) {\n \t\t\tret = 1;\n \t\t\tgoto next;\n \t\t}\n \n \t\titem = string_list_append(&refs_to_delete, name);\n-\t\titem->util = xstrdup((flags & REF_ISBROKEN) ? \"broken\"\n-\t\t\t\t    : (flags & REF_ISSYMREF) ? target\n+\t\titem->util = xstrdup((ref_flags & REF_ISBROKEN) ? \"broken\"\n+\t\t\t\t    : (ref_flags & REF_ISSYMREF) ? target\n \t\t\t\t    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));\n \n \tnext:\n@@ -331,7 +337,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,\n \t\tchar *name = item->string;\n \t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n-\t\t\tif (!quiet)\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n \t\t\t\t\t? _(\"Deleted remote-tracking branch %s (was %s).\\n\")\n \t\t\t\t\t: _(\"Deleted branch %s (was %s).\\n\"),\n@@ -896,7 +902,9 @@ int cmd_branch(int argc,\n \tif (delete) {\n \t\tif (!argc)\n \t\t\tdie(_(\"branch name required\"));\n-\t\tret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);\n+\t\tret = delete_branches(argc, argv, filter.kind,\n+\t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n+\t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n-- \ngitgitgadget\n\n"},{"id":"549702","messageId":"83cc3fd6a5e4da6204f1b97e76d33dd431abc5af.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 4/7] branch: prepare delete_branches for a bulk caller","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:34Z","receivedAt":"2026-08-05T14:24:49Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nTeach delete_branches() a new mode for the upcoming --delete-merged\ncaller that checks whether a branch is merged into its upstream without\nfalling back to HEAD when there is no upstream. Existing callers keep\ntheir current behavior.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n builtin/branch.c | 13 +++++++++----\n 1 file changed, 9 insertions(+), 4 deletions(-)\n\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex c44f710a48..7b0aa68572 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,\n \t * upstream, if any, otherwise with HEAD\", we should just\n \t * return the result of the repo_in_merge_bases() above without\n \t * any of the following code, but during the transition period,\n-\t * a gentle reminder is in order.\n+\t * a gentle reminder is in order.  Callers that opt out of the\n+\t * HEAD fallback by passing head_rev=NULL are not interested in\n+\t * the reminder either: they have already established that the\n+\t * branch has an upstream, so HEAD is irrelevant to the decision.\n \t */\n-\tif (head_rev != reference_rev) {\n-\t\tint expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;\n+\tif (head_rev && head_rev != reference_rev) {\n+\t\tint expect = repo_in_merge_bases(the_repository, rev, head_rev);\n \t\tif (expect < 0)\n \t\t\texit(128);\n \t\tif (expect == merged)\n@@ -193,6 +196,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_FORCE = (1 << 0),\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n+\tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -262,7 +266,8 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t}\n \tbranch_name_pos = strcspn(fmt, \"%\");\n \n-\tif (!(flags & DELETE_BRANCH_FORCE))\n+\tif (!(flags & DELETE_BRANCH_FORCE) &&\n+\t    !(flags & DELETE_BRANCH_NO_HEAD_FALLBACK))\n \t\thead_rev = lookup_commit_reference(the_repository, &head_oid);\n \n \tfor (i = 0; i < argc; i++, strbuf_reset(&bname)) {\n-- \ngitgitgadget\n\n"},{"id":"549703","messageId":"a5ef60a39415bd2a2c7593225e0c5522246ae2ac.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 5/7] branch: add --delete-merged <pattern>","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:35Z","receivedAt":"2026-08-05T14:24:51Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n    git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n\ndeletes local branches matching the optional branch patterns when their\nconfigured upstream matches one of the --delete-merged arguments and\ntheir tip is reachable from that upstream. The work has already landed\non the upstream they track, so the local copy is no longer needed.\n\nEach <pattern> may name a ref, a remote, or a shell glob. The option can\nbe repeated to widen the upstream match. Keeping the candidate patterns\nas positional arguments lets users bound the set of local branches that\nmay be deleted independently of the upstream selection.\n\nA branch is not deleted when:\n\n  * it is checked out in any worktree\n  * its configured upstream ref no longer exists, since a missing\n    upstream is not by itself a sign of integration\n  * pushing it to the remote configured by branch.<name>.remote would\n    update its upstream, as determined by that remote's configured push\n    and fetch refspecs. For example, a local \"main\" that tracks\n    \"origin/main\" is kept even when remote.pushDefault names a fork.\n    Right after a pull it merely looks fully merged.\n  * it is the local upstream of a branch that is not being deleted, so\n    no branch is deleted out from under stacked work.\n\nA branch whose work is not yet merged into its upstream is silently\nskipped, so one unmerged topic does not abort the whole sweep.\n\nCollect protected local upstreams without changing the candidate set\nduring ref iteration, then remove them after iteration. This makes the\nresult independent of ref iteration order. If a protected branch's own\nupstream is deleted by the same sweep, clear its upstream configuration.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  33 +++++\n builtin/branch.c              | 195 +++++++++++++++++++++++++++++-\n t/t3200-branch.sh             | 219 ++++++++++++++++++++++++++++++++++\n 3 files changed, 445 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex b0d66a6deb..4766178204 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n+git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n \n DESCRIPTION\n -----------\n@@ -201,6 +202,38 @@ This option is only applicable in non-verbose mode.\n \tPrint the name of the current branch. In detached `HEAD` state,\n \tnothing is printed.\n \n+`--delete-merged <pattern>`::\n+\tDelete local branches whose configured upstream matches\n+\t_<pattern>_, but only when their tip is reachable from that\n+\tupstream. In other words, the work on the branch has already\n+\tlanded on the upstream it tracks, so the local copy is no longer\n+\tneeded. _<pattern>_ may name a ref, a remote (using the branch its\n+\t`HEAD` points at), or a shell-style glob. The option can be\n+\trepeated to widen the upstream match.\n+\tOptional _<branch-pattern>_ arguments limit which local branches\n+\tare considered, e.g. `git branch --delete-merged 'origin/*'\n+\t'topic-*'`.\n++\n+A branch is not deleted when:\n++\n+--\n+* its configured upstream ref no longer exists,\n+* it is checked out in any worktree,\n+* pushing it to the remote configured by\n+  `branch.<name>.remote` would update its upstream, so it cannot be\n+  distinguished from a branch that just looks fully merged right\n+  after a pull; this is determined by the remote's configured push and\n+  fetch refspecs,\n+* it is the local upstream of a branch that is not being deleted.\n+--\n++\n+When such a local upstream branch has its own upstream deleted by the\n+same operation, its upstream configuration is cleared.\n++\n+A branch whose work has not yet been merged into its upstream is\n+silently skipped. Delete it with `git branch -D` if you want to\n+remove it anyway.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 7b0aa68572..f1a73bcea1 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -21,6 +21,7 @@\n #include \"branch.h\"\n #include \"path.h\"\n #include \"string-list.h\"\n+#include \"strmap.h\"\n #include \"column.h\"\n #include \"utf8.h\"\n #include \"ref-filter.h\"\n@@ -38,6 +39,8 @@ static const char * const builtin_branch_usage[] = {\n \tN_(\"git branch [<options>] (-c | -C) [<old-branch>] <new-branch>\"),\n \tN_(\"git branch [<options>] [-r | -a] [--points-at]\"),\n \tN_(\"git branch [<options>] [-r | -a] [--format]\"),\n+\tN_(\"git branch [<options>] (--delete-merged <pattern>)... \"\n+\t   \"[<branch-pattern>...]\"),\n \tNULL\n };\n \n@@ -700,6 +703,184 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset\n \treturn 0;\n }\n \n+struct stacked_branch_data {\n+\tstruct strset *deletable_branch_names;\n+\tstruct strset *protected_branch_names;\n+};\n+\n+static int collect_stacked_branch_base(const struct reference *ref,\n+\t\t\t\t       void *cb_data)\n+{\n+\tstruct stacked_branch_data *data = cb_data;\n+\tconst char *branch_name;\n+\tstruct branch *branch;\n+\tconst char *upstream_refname;\n+\tconst char *upstream_branch_name;\n+\n+\tif (!skip_prefix(ref->name, \"refs/heads/\", &branch_name))\n+\t\tBUG(\"expected local branch ref, got '%s'\", ref->name);\n+\tif (strset_contains(data->deletable_branch_names, branch_name))\n+\t\treturn 0;\n+\n+\tbranch = branch_get(branch_name);\n+\tupstream_refname = branch_get_upstream(branch, NULL);\n+\tif (!upstream_refname ||\n+\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t &upstream_branch_name) ||\n+\t    !strset_contains(data->deletable_branch_names,\n+\t\t\t    upstream_branch_name))\n+\t\treturn 0;\n+\n+\tstrset_add(data->protected_branch_names, upstream_branch_name);\n+\treturn 0;\n+}\n+\n+static void protect_stacked_branch_bases(struct ref_store *refs,\n+\t\t\t\t\t struct strset *deletable_branch_names,\n+\t\t\t\t\t struct strset *protected_branch_names)\n+{\n+\tstruct stacked_branch_data data = {\n+\t\t.deletable_branch_names = deletable_branch_names,\n+\t\t.protected_branch_names = protected_branch_names,\n+\t};\n+\tstruct refs_for_each_ref_options opts = {\n+\t\t.prefix = \"refs/heads/\",\n+\t};\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\trefs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);\n+\n+\tstrset_for_each_entry(protected_branch_names, &iter, entry)\n+\t\tstrset_remove(deletable_branch_names, entry->key);\n+}\n+\n+static void clear_deleted_upstreams(struct strset *protected_branch_names,\n+\t\t\t\t    struct strset *deletable_branch_names)\n+{\n+\tstruct strbuf key = STRBUF_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\n+\tstrset_for_each_entry(protected_branch_names, &iter, entry) {\n+\t\tstruct branch *branch = branch_get(entry->key);\n+\t\tconst char *upstream_refname = branch_get_upstream(branch, NULL);\n+\t\tconst char *upstream_branch_name;\n+\n+\t\tif (!upstream_refname ||\n+\t\t    !skip_prefix(upstream_refname, \"refs/heads/\",\n+\t\t\t\t &upstream_branch_name) ||\n+\t\t    !strset_contains(deletable_branch_names,\n+\t\t\t\t    upstream_branch_name))\n+\t\t\tcontinue;\n+\n+\t\tstrbuf_addf(&key, \"branch.%s.merge\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.remote\", branch->name);\n+\t\trepo_config_set_gently(the_repository, key.buf, NULL);\n+\t\tstrbuf_reset(&key);\n+\t}\n+\n+\tstrbuf_release(&key);\n+}\n+\n+static int branch_pushes_to_upstream(struct branch *branch,\n+\t\t\t\t     const char *upstream)\n+{\n+\tstruct remote *remote = remote_get(remote_for_branch(branch, NULL));\n+\tchar *push_refname = NULL;\n+\tchar *tracking = NULL;\n+\tint ret = 0;\n+\n+\tif (!remote)\n+\t\treturn 0;\n+\tif (remote->push.nr)\n+\t\tpush_refname = apply_refspecs(&remote->push, branch->refname);\n+\telse\n+\t\tpush_refname = xstrdup(branch->refname);\n+\tif (push_refname)\n+\t\ttracking = apply_refspecs(&remote->fetch, push_refname);\n+\tif (tracking && !strcmp(tracking, upstream))\n+\t\tret = 1;\n+\n+\tfree(push_refname);\n+\tfree(tracking);\n+\treturn ret;\n+}\n+\n+static int delete_merged_branches(const struct strvec *upstreams,\n+\t\t\t\t const char **argv, unsigned int flags)\n+{\n+\tstruct ref_store *refs = get_main_ref_store(the_repository);\n+\tstruct ref_filter filter = REF_FILTER_INIT;\n+\tstruct ref_array candidates = { 0 };\n+\tstruct strset deletable_branch_names = STRSET_INIT;\n+\tstruct strset protected_branch_names = STRSET_INIT;\n+\tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct hashmap_iter iter;\n+\tstruct strmap_entry *entry;\n+\tint ret = 0;\n+\n+\tfor (size_t i = 0; i < upstreams->nr; i++)\n+\t\tif (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)\n+\t\t\tdie(_(\"'%s' is not a valid branch or pattern\"),\n+\t\t\t    upstreams->v[i]);\n+\n+\tfilter.kind = FILTER_REFS_BRANCHES;\n+\tfilter.name_patterns = argv;\n+\tfilter_refs(&candidates, &filter, filter.kind);\n+\n+\tfor (int i = 0; i < candidates.nr; i++) {\n+\t\tconst char *branch_refname = candidates.items[i]->refname;\n+\t\tconst char *branch_name;\n+\t\tstruct branch *branch;\n+\t\tconst char *upstream_refname;\n+\n+\t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n+\t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n+\t\tif (branch_checked_out(branch_refname))\n+\t\t\tcontinue;\n+\n+\t\tbranch = branch_get(branch_name);\n+\t\tupstream_refname = branch_get_upstream(branch, NULL);\n+\t\tif (!upstream_refname || !refs_ref_exists(refs, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (branch_pushes_to_upstream(branch, upstream_refname))\n+\t\t\tcontinue;\n+\t\tif (check_branch_commit(branch_name, branch_name,\n+\t\t\t\t\t&candidates.items[i]->objectname, NULL,\n+\t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n+\t\t\tcontinue;\n+\n+\t\tstrset_add(&deletable_branch_names, branch_name);\n+\t}\n+\n+\tprotect_stacked_branch_bases(refs, &deletable_branch_names,\n+\t\t\t\t     &protected_branch_names);\n+\n+\tstrset_for_each_entry(&deletable_branch_names, &iter, entry)\n+\t\tstrvec_push(&branches_to_delete, entry->key);\n+\n+\tif (branches_to_delete.nr)\n+\t\tret = delete_branches(branches_to_delete.nr, branches_to_delete.v,\n+\t\t\t\t      FILTER_REFS_BRANCHES,\n+\t\t\t\t      DELETE_BRANCH_SKIP_UNMERGED |\n+\t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n+\t\t\t\t      flags);\n+\n+\tif (!ret)\n+\t\tclear_deleted_upstreams(&protected_branch_names,\n+\t\t\t\t\t&deletable_branch_names);\n+\n+\tstrvec_clear(&branches_to_delete);\n+\tstrset_clear(&protected_branch_names);\n+\tstrset_clear(&deletable_branch_names);\n+\tref_array_clear(&candidates);\n+\tref_filter_clear(&filter);\n+\treturn ret;\n+}\n+\n static GIT_PATH_FUNC(edit_description, \"EDIT_DESCRIPTION\")\n \n static int edit_branch_description(const char *branch_name)\n@@ -764,6 +945,7 @@ int cmd_branch(int argc,\n \t/* possible actions */\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n+\tstruct strvec delete_merged = STRVEC_INIT;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -817,6 +999,9 @@ int cmd_branch(int argc,\n \t\tOPT_BOOL(0, \"create-reflog\", &reflog, N_(\"create the branch's reflog\")),\n \t\tOPT_BOOL(0, \"edit-description\", &edit_description,\n \t\t\t N_(\"edit the description for the branch\")),\n+\t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n+\t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n+\t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -864,7 +1049,8 @@ int cmd_branch(int argc,\n \t\t\t     0);\n \n \tif (!delete && !rename && !copy && !edit_description && !new_upstream &&\n-\t    !show_current && !unset_upstream && argc == 0)\n+\t    !show_current && !unset_upstream && !delete_merged.nr &&\n+\t    argc == 0)\n \t\tlist = 1;\n \n \tif (filter.with_commit || filter.no_commit ||\n@@ -874,7 +1060,7 @@ int cmd_branch(int argc,\n \n \tnoncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +\n \t\t\t    !!show_current + !!list + !!edit_description +\n-\t\t\t    !!unset_upstream;\n+\t\t\t    !!unset_upstream + !!delete_merged.nr;\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n@@ -916,6 +1102,10 @@ int cmd_branch(int argc,\n \t\t\t\t      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |\n \t\t\t\t      (quiet ? DELETE_BRANCH_QUIET : 0));\n \t\tgoto out;\n+\t} else if (delete_merged.nr) {\n+\t\tret = delete_merged_branches(&delete_merged, argv,\n+\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\n \t\tret = 0;\n@@ -1087,6 +1277,7 @@ int cmd_branch(int argc,\n \tret = 0;\n \n out:\n+\tstrvec_clear(&delete_merged);\n \tstring_list_clear(&sorting_options, 0);\n \treturn ret;\n }\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex c2f3fd8fa8..496072a3fe 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1884,4 +1884,223 @@ test_expect_success '--forked requires a value' '\n \ttest_grep \"requires a value\" err\n '\n \n+test_expect_success '--delete-merged: setup' '\n+\tgit init -b main upstream &&\n+\t(\n+\t\tcd upstream &&\n+\t\ttest_commit base &&\n+\t\tgit checkout -b next &&\n+\t\ttest_commit next-work &&\n+\t\tgit checkout main\n+\t) &&\n+\tgit init -b main other &&\n+\ttest_commit -C other other-base &&\n+\tgit init -b main fork\n+'\n+\n+setup_repo_for_delete_merged () {\n+\trm -rf repo &&\n+\tgit clone upstream repo &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit remote add fork ../fork &&\n+\t\tgit remote add other ../other &&\n+\t\tgit config push.default current &&\n+\t\tgit fetch other\n+\t)\n+}\n+\n+create_merged_branch () {\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b \"$1\" --track origin/next &&\n+\t\tgit commit --allow-empty -m \"$1 work\" &&\n+\t\tgit push origin \"$1:next\"\n+\t)\n+}\n+\n+check_branches () {\n+\tgit for-each-ref --format=\"%(refname:short)\" refs/heads/ >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_cmp_config origin branch.main.remote &&\n+\t\ttest_cmp_config refs/heads/main branch.main.merge &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged */* &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged deletes only selected merged branches' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch also-merged &&\n+\tcreate_merged_branch merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b unmerged --track origin/next &&\n+\t\tgit commit --allow-empty -m \"unmerged work\" &&\n+\t\tgit checkout -b tracks-other --track other/main &&\n+\t\tsha=$(git rev-parse --short merged) &&\n+\n+\t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n+\t\techo \"Deleted branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\talso-merged\n+\t\tmain\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps main despite a different default push remote' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch on-next &&\n+\tcreate_merged_branch checked-out &&\n+\tcreate_merged_branch upstream-gone &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config remote.pushDefault fork &&\n+\t\tgit checkout -b local-to-delete --track main &&\n+\t\tgit config branch.upstream-gone.merge refs/heads/topic &&\n+\t\tgit checkout -b tracks-other --track other/main &&\n+\t\tgit checkout checked-out &&\n+\n+\t\tgit branch --delete-merged origin/* --delete-merged main &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tchecked-out\n+\t\tmain\n+\t\ttracks-other\n+\t\tupstream-gone\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged maps push refspecs to upstreams' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic &&\n+\t\tgit config remote.origin.push \\\n+\t\t\t\"refs/heads/topic:refs/heads/published\" &&\n+\t\tgit push origin &&\n+\t\tgit branch --set-upstream-to=origin/published topic &&\n+\t\tgit checkout -b other-topic --track origin/published &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/published &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch feature &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit checkout -b topic --track feature &&\n+\t\tgit commit --allow-empty -m \"topic work\" &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_must_be_empty err &&\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tfeature\n+\t\tmain\n+\t\ttopic\n+\t\tEOF\n+\n+\t\tpattern=\"branch\\\\.(feature|topic)\\\\.(merge|remote)\" &&\n+\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.feature.remote origin\n+\t\tbranch.feature.merge refs/heads/next\n+\t\tbranch.topic.remote .\n+\t\tbranch.topic.merge refs/heads/feature\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch --track lower origin/next &&\n+\t\tgit branch --track mid lower &&\n+\t\tgit checkout -b tip --track mid &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\t\tsha=$(git rev-parse --short lower) &&\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\techo \"Deleted branch lower (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\tmain\n+\t\tmid\n+\t\ttip\n+\t\tEOF\n+\n+\t\tpattern=\"branch\\\\.(mid|tip)\\\\.(merge|remote)\" &&\n+\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--delete-merged result is independent of stacked branch names' '\n+\tsetup_repo_for_delete_merged &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit branch --track c-lower origin/next &&\n+\t\tgit branch --track b-mid c-lower &&\n+\t\tgit checkout -b a-tip --track b-mid &&\n+\t\tgit commit --allow-empty -m \"tip work\" &&\n+\n+\t\tgit branch --delete-merged origin/next --delete-merged \"c-*\" &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\ta-tip\n+\t\tb-mid\n+\t\tmain\n+\t\tEOF\n+\n+\t\tgit branch --delete-merged origin/next \\\n+\t\t\t--delete-merged \"c-*\" >actual 2>&1 &&\n+\t\ttest_must_be_empty actual &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\ta-tip\n+\t\tb-mid\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--delete-merged requires a value' '\n+\ttest_must_fail git -C forked branch --delete-merged 2>err &&\n+\ttest_grep \"requires a value\" err\n+'\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549705","messageId":"c2d1fdebe81450288f9b86c40f7c9ff70dd8630d.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 6/7] branch: add branch.<name>.deleteMerged opt-out","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:36Z","receivedAt":"2026-08-05T14:24:54Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\nSetting branch.<name>.deleteMerged=false exempts that branch from\n\"git branch --delete-merged\", which is useful for a topic you want\nto keep developing after an early round of it has been merged\nupstream. Unless --quiet is given, each skip is reported so the\nuser knows why their topic was kept.\n\nExplicit deletion with \"git branch -d\" still uses the normal merge\ncheck and ignores this setting.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/config/branch.adoc |  7 +++++++\n Documentation/git-branch.adoc    |  3 ++-\n builtin/branch.c                 | 14 +++++++++++++\n t/t3200-branch.sh                | 36 ++++++++++++++++++++++++++++++++\n 4 files changed, 59 insertions(+), 1 deletion(-)\n\ndiff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc\nindex 5a85fde8de..c2acd89c99 100644\n--- a/Documentation/config/branch.adoc\n+++ b/Documentation/config/branch.adoc\n@@ -103,3 +103,10 @@ for details).\n \t`git branch --edit-description`. Branch description is\n \tautomatically added to the `format-patch` cover letter or\n \t`request-pull` summary.\n+\n+`branch.<name>.deleteMerged`::\n+\tIf set to `false`, branch _<name>_ is exempt from\n+\t`git branch --delete-merged`.  Useful for a topic branch you\n+\tintend to develop further after an initial round has been\n+\tmerged upstream.  Defaults to true.  Explicit deletion via\n+\t`git branch -d` is unaffected.\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex 4766178204..cfaac4b90f 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -224,7 +224,8 @@ A branch is not deleted when:\n   distinguished from a branch that just looks fully merged right\n   after a pull; this is determined by the remote's configured push and\n   fetch refspecs,\n-* it is the local upstream of a branch that is not being deleted.\n+* it is the local upstream of a branch that is not being deleted, or\n+* `branch.<name>.deleteMerged` is set to `false`.\n --\n +\n When such a local upstream branch has its own upstream deleted by the\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex f1a73bcea1..2d0c4f51ea 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -818,6 +818,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \tstruct strset deletable_branch_names = STRSET_INIT;\n \tstruct strset protected_branch_names = STRSET_INIT;\n \tstruct strvec branches_to_delete = STRVEC_INIT;\n+\tstruct strbuf key = STRBUF_INIT;\n \tstruct hashmap_iter iter;\n \tstruct strmap_entry *entry;\n \tint ret = 0;\n@@ -836,6 +837,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tconst char *branch_name;\n \t\tstruct branch *branch;\n \t\tconst char *upstream_refname;\n+\t\tint opt_out;\n \n \t\tif (!skip_prefix(branch_refname, \"refs/heads/\", &branch_name))\n \t\t\tBUG(\"filter returned non-branch ref '%s'\", branch_refname);\n@@ -853,6 +855,17 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t\tFILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))\n \t\t\tcontinue;\n \n+\t\tstrbuf_reset(&key);\n+\t\tstrbuf_addf(&key, \"branch.%s.deletemerged\", branch_name);\n+\t\tif (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&\n+\t\t    !opt_out) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tfprintf(stderr,\n+\t\t\t\t\t_(\"Skipping '%s' (branch.%s.deleteMerged is false)\\n\"),\n+\t\t\t\t\tbranch_name, branch_name);\n+\t\t\tcontinue;\n+\t\t}\n+\n \t\tstrset_add(&deletable_branch_names, branch_name);\n \t}\n \n@@ -873,6 +886,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\tclear_deleted_upstreams(&protected_branch_names,\n \t\t\t\t\t&deletable_branch_names);\n \n+\tstrbuf_release(&key);\n \tstrvec_clear(&branches_to_delete);\n \tstrset_clear(&protected_branch_names);\n \tstrset_clear(&deletable_branch_names);\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 496072a3fe..31ca1719d9 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -2103,4 +2103,40 @@ test_expect_success '--delete-merged requires a value' '\n \ttest_must_fail git -C forked branch --delete-merged 2>err &&\n \ttest_grep \"requires a value\" err\n '\n+\n+test_expect_success '--delete-merged honours branch.<name>.deleteMerged=false' '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch deleted &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch --delete-merged origin/next 2>err &&\n+\n+\t\ttest_grep \"Skipping .kept.\" err &&\n+\t\tcheck_branches <<-\\EOF\n+\t\tkept\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n+\tsetup_repo_for_delete_merged &&\n+\tcreate_merged_branch kept &&\n+\t(\n+\t\tcd repo &&\n+\t\tgit config branch.kept.deleteMerged false &&\n+\t\tgit checkout --detach &&\n+\n+\t\tgit branch -d kept &&\n+\n+\t\tcheck_branches <<-\\EOF\n+\t\tmain\n+\t\tEOF\n+\t)\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"549709","messageId":"5a936bcb93f04bed0db33cf4cce19e194324e11e.1785939877.git.gitgitgadget@gmail.com","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"[PATCH v25 7/7] branch: add --dry-run for --delete-merged","fromName":"Harald Nordgren via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-08-05T14:24:37Z","receivedAt":"2026-08-05T14:24:54Z","isPatch":true,"body":"From: Harald Nordgren <haraldnordgren@gmail.com>\n\n\"git branch --dry-run --delete-merged ...\" prints one line per ref that\nwould be deleted without modifying refs or branch configuration.\n\n--dry-run is only meaningful together with --delete-merged and is\nrejected otherwise.\n\nSigned-off-by: Harald Nordgren <haraldnordgren@gmail.com>\n---\n Documentation/git-branch.adoc |  8 ++++++-\n builtin/branch.c              | 23 ++++++++++++++++----\n t/t3200-branch.sh             | 41 ++++++++++++++++++++++++++++++++++-\n 3 files changed, 66 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc\nindex cfaac4b90f..bfdf459329 100644\n--- a/Documentation/git-branch.adoc\n+++ b/Documentation/git-branch.adoc\n@@ -25,7 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>\n git branch (-c|-C) [<old-branch>] <new-branch>\n git branch (-d|-D) [-r] <branch-name>...\n git branch --edit-description [<branch-name>]\n-git branch (--delete-merged <pattern>)... [<branch-pattern>...]\n+git branch [--dry-run] (--delete-merged <pattern>)... [<branch-pattern>...]\n \n DESCRIPTION\n -----------\n@@ -235,6 +235,12 @@ A branch whose work has not yet been merged into its upstream is\n silently skipped. Delete it with `git branch -D` if you want to\n remove it anyway.\n \n+`--dry-run`::\n+\tWith `--delete-merged`, print which branches would be\n+\tdeleted and exit without touching any ref.  Useful for\n+\tsanity-checking a wide pattern like `'origin/*'` before\n+\tcommitting to the deletion.\n+\n `-v`::\n `-vv`::\n `--verbose`::\ndiff --git a/builtin/branch.c b/builtin/branch.c\nindex 2d0c4f51ea..57ee384d23 100644\n--- a/builtin/branch.c\n+++ b/builtin/branch.c\n@@ -200,6 +200,7 @@ enum delete_branch_flags {\n \tDELETE_BRANCH_QUIET = (1 << 1),\n \tDELETE_BRANCH_SKIP_UNMERGED = (1 << 2),\n \tDELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),\n+\tDELETE_BRANCH_DRY_RUN = (1 << 4),\n };\n \n static int check_branch_commit(const char *branchname, const char *refname,\n@@ -342,13 +343,20 @@ static int delete_branches(int argc, const char **argv, int kinds,\n \t\tfree(target);\n \t}\n \n-\tif (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n+\tif (!(flags & DELETE_BRANCH_DRY_RUN) &&\n+\t    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))\n \t\tret = 1;\n \n \tfor_each_string_list_item(item, &refs_to_delete) {\n \t\tchar *describe_ref = item->util;\n \t\tchar *name = item->string;\n-\t\tif (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n+\t\tif (flags & DELETE_BRANCH_DRY_RUN) {\n+\t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n+\t\t\t\tprintf(remote_branch\n+\t\t\t\t\t? _(\"Would delete remote-tracking branch %s (was %s).\\n\")\n+\t\t\t\t\t: _(\"Would delete branch %s (was %s).\\n\"),\n+\t\t\t\t\tname + branch_name_pos, describe_ref);\n+\t\t} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {\n \t\t\tchar *refname = name + branch_name_pos;\n \t\t\tif (!(flags & DELETE_BRANCH_QUIET))\n \t\t\t\tprintf(remote_branch\n@@ -882,7 +890,7 @@ static int delete_merged_branches(const struct strvec *upstreams,\n \t\t\t\t      DELETE_BRANCH_NO_HEAD_FALLBACK |\n \t\t\t\t      flags);\n \n-\tif (!ret)\n+\tif (!ret && !(flags & DELETE_BRANCH_DRY_RUN))\n \t\tclear_deleted_upstreams(&protected_branch_names,\n \t\t\t\t\t&deletable_branch_names);\n \n@@ -960,6 +968,7 @@ int cmd_branch(int argc,\n \tint delete = 0, rename = 0, copy = 0, list = 0,\n \t    unset_upstream = 0, show_current = 0, edit_description = 0;\n \tstruct strvec delete_merged = STRVEC_INIT;\n+\tint dry_run = 0;\n \tconst char *new_upstream = NULL;\n \tint noncreate_actions = 0;\n \t/* possible options */\n@@ -1016,6 +1025,8 @@ int cmd_branch(int argc,\n \t\tOPT_CALLBACK_F(0, \"delete-merged\", &delete_merged, N_(\"pattern\"),\n \t\t\tN_(\"delete merged branches whose upstream matches <pattern> (repeatable)\"),\n \t\t\tPARSE_OPT_NONEG, parse_opt_strvec),\n+\t\tOPT_BOOL(0, \"dry-run\", &dry_run,\n+\t\t\tN_(\"with --delete-merged, only print which branches would be deleted\")),\n \t\tOPT__FORCE(&force, N_(\"force creation, move/rename, deletion\"), PARSE_OPT_NOCOMPLETE),\n \t\tOPT_MERGED(&filter, N_(\"print only branches that are merged\")),\n \t\tOPT_NO_MERGED(&filter, N_(\"print only branches that are not merged\")),\n@@ -1078,6 +1089,9 @@ int cmd_branch(int argc,\n \tif (noncreate_actions > 1)\n \t\tusage_with_options(builtin_branch_usage, options);\n \n+\tif (dry_run && !delete_merged.nr)\n+\t\tdie(_(\"--dry-run requires --delete-merged\"));\n+\n \tif (recurse_submodules_explicit) {\n \t\tif (!submodule_propagate_branches)\n \t\t\tdie(_(\"branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled\"));\n@@ -1118,7 +1132,8 @@ int cmd_branch(int argc,\n \t\tgoto out;\n \t} else if (delete_merged.nr) {\n \t\tret = delete_merged_branches(&delete_merged, argv,\n-\t\t\t\t\t     quiet ? DELETE_BRANCH_QUIET : 0);\n+\t\t\t\t\t     (quiet ? DELETE_BRANCH_QUIET : 0) |\n+\t\t\t\t\t     (dry_run ? DELETE_BRANCH_DRY_RUN : 0));\n \t\tgoto out;\n \t} else if (show_current) {\n \t\tprint_current_branch_name();\ndiff --git a/t/t3200-branch.sh b/t/t3200-branch.sh\nindex 31ca1719d9..43fea342d1 100755\n--- a/t/t3200-branch.sh\n+++ b/t/t3200-branch.sh\n@@ -1952,6 +1952,20 @@ test_expect_success '--delete-merged deletes only selected merged branches' '\n \t\tgit checkout -b tracks-other --track other/main &&\n \t\tsha=$(git rev-parse --short merged) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next merged \\\n+\t\t\t>actual 2>&1 &&\n+\t\techo \"Would delete branch merged (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit rev-parse --verify refs/heads/merged &&\n+\n+\t\tcheck_branches <<-\\EOF &&\n+\t\talso-merged\n+\t\tmain\n+\t\tmerged\n+\t\ttracks-other\n+\t\tunmerged\n+\t\tEOF\n+\n \t\tgit branch --delete-merged origin/next merged >actual 2>&1 &&\n \t\techo \"Deleted branch merged (was $sha).\" >expect &&\n \t\ttest_cmp expect actual &&\n@@ -2018,9 +2032,12 @@ test_expect_success '--delete-merged keeps the upstream of a surviving branch' '\n \t\tgit checkout -b topic --track feature &&\n \t\tgit commit --allow-empty -m \"topic work\" &&\n \n-\t\tgit branch --delete-merged origin/next 2>err &&\n+\t\tgit branch --dry-run --delete-merged origin/next >out &&\n+\t\ttest_grep ! \"feature\" out &&\n \n+\t\tgit branch --delete-merged origin/next 2>err &&\n \t\ttest_must_be_empty err &&\n+\n \t\tcheck_branches <<-\\EOF &&\n \t\tfeature\n \t\tmain\n@@ -2049,6 +2066,23 @@ test_expect_success '--delete-merged clears the deleted upstream of a protected\n \t\tgit commit --allow-empty -m \"tip work\" &&\n \t\tsha=$(git rev-parse --short lower) &&\n \n+\t\tgit branch --dry-run --delete-merged origin/next \\\n+\t\t\t--delete-merged lower >actual 2>&1 &&\n+\t\techo \"Would delete branch lower (was $sha).\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tpattern=\"branch\\\\.(lower|mid|tip)\\\\.(merge|remote)\" &&\n+\t\tgit config --local --get-regexp \"$pattern\" >actual &&\n+\t\tcat >expect <<-\\EOF &&\n+\t\tbranch.lower.remote origin\n+\t\tbranch.lower.merge refs/heads/next\n+\t\tbranch.mid.remote .\n+\t\tbranch.mid.merge refs/heads/lower\n+\t\tbranch.tip.remote .\n+\t\tbranch.tip.merge refs/heads/mid\n+\t\tEOF\n+\t\ttest_cmp expect actual &&\n+\n \t\tgit branch --delete-merged origin/next \\\n \t\t\t--delete-merged lower >actual 2>&1 &&\n \t\techo \"Deleted branch lower (was $sha).\" >expect &&\n@@ -2139,4 +2173,9 @@ test_expect_success \"branch -d still deletes a deleteMerged=false branch\" '\n \t)\n '\n \n+test_expect_success '--dry-run without --delete-merged is rejected' '\n+\ttest_must_fail git -C forked branch --dry-run 2>err &&\n+\ttest_grep \"requires --delete-merged\" err\n+'\n+\n test_done\n-- \ngitgitgadget\n"},{"id":"549876","messageId":"xmqqcxvvjg5p.fsf@gitster.g","threadId":"65574","inReplyTo":"pull.2285.v25.git.git.1785939877.gitgitgadget@gmail.com","subject":"Re: [PATCH v25 0/7] branch: delete-merged","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-08-06T17:46:26Z","receivedAt":"2026-08-06T17:46:29Z","isPatch":true,"body":"\"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> Delete branches that have already been merged on upstream.\n>\n> Changes in v25:\n>\n>  * Wrap long lines in tests.\n\nThe interdiff looks just as expected.\n\nShall we mark the topic for 'next' now?\n\nThanks.\n"},{"id":"549995","messageId":"10b22a6b-e028-469c-a117-8dc2ed5bed3b@gmail.com","threadId":"65574","inReplyTo":"xmqqcxvvjg5p.fsf@gitster.g","subject":"Re: [PATCH v25 0/7] branch: delete-merged","fromName":"Phillip Wood","fromEmail":"phillip.wood123@gmail.com","sentAt":"2026-08-07T13:09:14Z","receivedAt":"2026-08-07T13:09:18Z","isPatch":true,"body":"On 06/08/2026 18:46, Junio C Hamano wrote:\n> \"Harald Nordgren via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n> \n>> Delete branches that have already been merged on upstream.\n>>\n>> Changes in v25:\n>>\n>>   * Wrap long lines in tests.\n> \n> The interdiff looks just as expected.\n> \n> Shall we mark the topic for 'next' now?\n\nIt looks ready to me\n\nThanks\n\nPhillip\n\n"}]}