Subject: [PATCH v9 0/5] branch: prune-merged

From: "Harald Nordgren via GitGitGadget" <[email-masked]>
Date: Wed, 13 May 2026 19:34:38 +0000

* --force no longer has special meaning with --prune-merged; reachability
   is always enforced. Use git branch -D to delete an unmerged branch.
   Matches how git branch's other read/safe actions treat --force.
 * Synopsis drops [-f]; "not fully merged" hint points at git branch -D.
 * Dropped the --prune-merged --force tests.

Harald Nordgren (5):
  branch: add --forked <remote>
  branch: let delete_branches warn instead of error on bulk refusal
  branch: add --prune-merged <remote>
  branch: add branch.<name>.pruneMerged opt-out
  branch: add --all-remotes flag

 Documentation/config/branch.adoc |   7 +
 Documentation/git-branch.adoc    |  37 ++++
 builtin/branch.c                 | 292 +++++++++++++++++++++++++++++--
 t/t3200-branch.sh                | 208 ++++++++++++++++++++++
 4 files changed, 528 insertions(+), 16 deletions(-)

base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v9
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v9
Pull-Request: https://github.com/git/git/pull/2285

Range-diff vs v8:

 1:  22fa8515df = 1:  9324b26091 branch: add --forked <remote>
 2:  b443f0f367 = 2:  2a13e5d4bc branch: let delete_branches warn instead of error on bulk refusal
 3:  3032e9c39a ! 3:  f87e96e99d branch: add --prune-merged <remote>
     @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
       git branch --forked <remote>...
     -+git branch [-f] --prune-merged <remote>...
     ++git branch --prune-merged <remote>...
       
       DESCRIPTION
       -----------
     @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi
      ++
      +A branch whose upstream no longer resolves locally is left alone
      +(its disappearance is not, on its own, evidence that the work was
     -+integrated). With `--force` (or `-f`), the reachability check is
     -+skipped and every branch in the candidate set is deleted. The
     -+currently checked-out branch in any worktree is always preserved,
     -+as is the local branch that mirrors _<remote>_'s default branch.
     ++integrated). The currently checked-out branch in any worktree is
     ++always preserved, as is the local branch that mirrors _<remote>_'s
     ++default branch.
      +
       `-v`::
       `-vv`::
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
       	return 0;
       }
       
     -+static int prune_merged_branches(int argc, const char **argv, int force,
     -+				 int quiet)
     ++static int prune_merged_branches(int argc, const char **argv, int quiet)
      +{
      +	struct string_list candidates = STRING_LIST_INIT_DUP;
      +	struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +
      +	if (deletable.nr)
      +		ret = delete_branches(deletable.nr, deletable.v,
     -+				      1, force,
     ++				      1, 0,
      +				      FILTER_REFS_BRANCHES, quiet,
      +				      1, &n_not_merged);
      +
      +	if (n_not_merged && !quiet)
      +		fprintf(stderr,
      +			Q_("Skipped %d branch that is not fully merged; "
     -+			   "re-run with --force to delete it anyway.\n",
     ++			   "delete it with 'git branch -D' if you are sure.\n",
      +			   "Skipped %d branches that are not fully merged; "
     -+			   "re-run with --force to delete them anyway.\n",
     ++			   "delete them with 'git branch -D' if you are sure.\n",
      +			   n_not_merged),
      +			n_not_merged);
      +
     @@ builtin/branch.c: int cmd_branch(int argc,
       		ret = list_forked_branches(argc, argv);
       		goto out;
      +	} else if (prune_merged) {
     -+		ret = prune_merged_branches(argc, argv, force, quiet);
     ++		ret = prune_merged_branches(argc, argv, quiet);
      +		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	git -C pm-unmerged branch --prune-merged origin 2>err &&
      +	test_grep "not fully merged" err &&
      +	test_grep "Skipped 1 branch" err &&
     -+	test_grep "re-run with --force" err &&
     ++	test_grep "git branch -D" err &&
      +	test_grep ! "If you are sure you want to delete it" err &&
      +	git -C pm-unmerged rev-parse --verify refs/heads/wip
      +'
      +
     -+test_expect_success '--prune-merged --force deletes branches regardless of reachability' '
     -+	test_when_finished "rm -rf pm-force" &&
     -+	git clone pm-upstream pm-force &&
     -+	git -C pm-force checkout -b wip origin/wip &&
     -+	git -C pm-force branch --set-upstream-to=origin/next wip &&
     -+	test_commit -C pm-force local-only &&
     -+	git -C pm-force checkout - &&
     -+
     -+	git -C pm-force branch --force --prune-merged origin &&
     -+
     -+	test_must_fail git -C pm-force rev-parse --verify refs/heads/wip
     -+'
     -+
      +test_expect_success '--prune-merged skips branches whose upstream is gone' '
      +	test_when_finished "rm -rf pm-upstream-gone" &&
      +	git clone pm-upstream pm-upstream-gone &&
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	git -C pm-head checkout -b one one-commit &&
      +	git -C pm-head branch --set-upstream-to=origin/next one &&
      +
     -+	git -C pm-head branch --force --prune-merged origin &&
     ++	git -C pm-head branch --prune-merged origin &&
      +
      +	git -C pm-head rev-parse --verify refs/heads/one
      +'
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	test_when_finished "rm -rf pm-default" &&
      +	git clone pm-upstream pm-default &&
      +	git -C pm-default checkout --detach &&
     -+	git -C pm-default branch --force --prune-merged origin &&
     ++	git -C pm-default branch --prune-merged origin &&
      +	git -C pm-default rev-parse --verify refs/heads/main
      +'
      +
 4:  dd33309344 ! 4:  19b6d94fa7 branch: add branch.<name>.pruneMerged opt-out
     @@ Documentation/config/branch.adoc: for details).
      +	Explicit deletion via `git branch -d` is unaffected.
      
       ## Documentation/git-branch.adoc ##
     -@@ Documentation/git-branch.adoc: A branch whose upstream no longer resolves locally is left alone
     - integrated). With `--force` (or `-f`), the reachability check is
     - skipped and every branch in the candidate set is deleted. The
     - currently checked-out branch in any worktree is always preserved,
     --as is the local branch that mirrors _<remote>_'s default branch.
     -+as is any branch with `branch.<name>.pruneMerged` set to `false`,
     -+and the local branch that mirrors _<remote>_'s default branch.
     +@@ Documentation/git-branch.adoc: against whatever the remote-tracking refs say locally.
     + A branch whose upstream no longer resolves locally is left alone
     + (its disappearance is not, on its own, evidence that the work was
     + integrated). The currently checked-out branch in any worktree is
     +-always preserved, as is the local branch that mirrors _<remote>_'s
     ++always preserved, as is any branch with `branch.<name>.pruneMerged`
     ++set to `false`, and the local branch that mirrors _<remote>_'s
     + default branch.
       
       `-v`::
     - `-vv`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
       	for_each_string_list_item(item, &candidates) {
       		const char *short_name = item->string;
       		struct strbuf full = STRBUF_INIT;
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       			continue;
       		}
       		strbuf_release(&full);
     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
       		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
       		if (!upstream ||
       		    !refs_ref_exists(get_main_ref_store(the_repository),
     @@ t/t3200-branch.sh: test_expect_success '--prune-merged protects only the default
      +	test_grep "Skipping .one." err
      +'
      +
     -+test_expect_success '--prune-merged --force still honours pruneMerged=false' '
     -+	test_when_finished "rm -rf pm-optout-force" &&
     -+	git clone pm-upstream pm-optout-force &&
     -+	git -C pm-optout-force checkout -b wip origin/wip &&
     -+	git -C pm-optout-force branch --set-upstream-to=origin/next wip &&
     -+	test_commit -C pm-optout-force local-only &&
     -+	git -C pm-optout-force checkout - &&
     -+	git -C pm-optout-force config branch.wip.pruneMerged false &&
     -+
     -+	git -C pm-optout-force branch --force --prune-merged origin &&
     -+
     -+	git -C pm-optout-force rev-parse --verify refs/heads/wip
     -+'
     -+
      +test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
      +	test_when_finished "rm -rf pm-optout-d" &&
      +	git clone pm-upstream pm-optout-d &&
 5:  6e81ed3147 ! 5:  6ae95d3f98 branch: add --all-remotes flag
     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
      -git branch --forked <remote>...
     --git branch [-f] --prune-merged <remote>...
     +-git branch --prune-merged <remote>...
      +git branch --forked (<remote>... | --all-remotes)
     -+git branch [-f] --prune-merged (<remote>... | --all-remotes)
     ++git branch --prune-merged (<remote>... | --all-remotes)
       
       DESCRIPTION
       -----------
     -@@ Documentation/git-branch.adoc: currently checked-out branch in any worktree is always preserved,
     - as is any branch with `branch.<name>.pruneMerged` set to `false`,
     - and the local branch that mirrors _<remote>_'s default branch.
     +@@ Documentation/git-branch.adoc: always preserved, as is any branch with `branch.<name>.pruneMerged`
     + set to `false`, and the local branch that mirrors _<remote>_'s
     + default branch.
       
      +`--all-remotes`::
      +	With `--forked` or `--prune-merged`, act on every
     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
       	return 0;
       }
       
     --static int prune_merged_branches(int argc, const char **argv, int force,
     --				 int quiet)
     +-static int prune_merged_branches(int argc, const char **argv, int quiet)
      +static int prune_merged_branches(int argc, const char **argv,
     -+				 int all_remotes, int force, int quiet)
     ++				 int all_remotes, int quiet)
       {
       	struct string_list candidates = STRING_LIST_INIT_DUP;
       	struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
     -@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int force,
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
       	int n_not_merged = 0;
       	int ret = 0;
       
     @@ builtin/branch.c: int cmd_branch(int argc,
       
      +	if (all_remotes && !forked && !prune_merged)
      +		die(_("--all-remotes requires --forked or --prune-merged"));
     ++
      +
       	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
       	    !show_current && !unset_upstream && !forked && !prune_merged &&
     @@ builtin/branch.c: int cmd_branch(int argc,
      +		ret = list_forked_branches(argc, argv, all_remotes);
       		goto out;
       	} else if (prune_merged) {
     --		ret = prune_merged_branches(argc, argv, force, quiet);
     -+		ret = prune_merged_branches(argc, argv, all_remotes, force, quiet);
     +-		ret = prune_merged_branches(argc, argv, quiet);
     ++		ret = prune_merged_branches(argc, argv, all_remotes, quiet);
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();

-- 
gitgitgadget