Subject: [PATCH v4 0/6] fetch: add fetch.pruneBranches config

From: "Harald Nordgren via GitGitGadget" <[email-masked]>
Date: Tue, 05 May 2026 19:23:52 +0000

I ran this on one of my repos and wiped out the local master branch! That
was a bad surprise, so I made an update now to treat the default branch in a
special way (never prune it).

 * Resolve each remote's HEAD and collect the targets into a
   protected_default_refs set in collect_forked_set.
 * In prune_merged_branches, skip a candidate when its upstream is a
   protected default ref and the local branch name matches the default
   branch's leaf name (so a local main tracking origin/main is spared, but a
   renamed trunk tracking origin/main is not).
 * Also skip when the candidate's push ref points at a protected default
   ref, so a topic branch configured to push to origin/main is never pruned.
 * Tests: spare the local default branch; only protect by matching leaf name
   (not by upstream alone); spare a branch whose push ref is the remote
   default.

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

 Documentation/config/branch.adoc |   7 +
 Documentation/fetch-options.adoc |   8 +
 Documentation/git-branch.adoc    |  32 ++++
 builtin/branch.c                 | 289 +++++++++++++++++++++++++++++--
 builtin/fetch.c                  |  20 +++
 t/t3200-branch.sh                | 247 ++++++++++++++++++++++++++
 t/t5510-fetch.sh                 |  31 ++++
 7 files changed, 623 insertions(+), 11 deletions(-)

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

Range-diff vs v3:

 1:  77e67d4b8b = 1:  77e67d4b8b branch: add --forked <remote>
 2:  807c9f981f = 2:  807c9f981f branch: let delete_branches warn instead of error on bulk refusal
 3:  49dc853403 ! 3:  77beb620d7 branch: add --prune-merged <remote>
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
       }
       
      -static int list_forked_branches(int argc, const char **argv)
     ++static void collect_default_branch_refs(const struct string_list *remote_names,
     ++					struct string_list *out)
     ++{
     ++	struct ref_store *refs = get_main_ref_store(the_repository);
     ++	struct string_list_item *item;
     ++
     ++	for_each_string_list_item(item, remote_names) {
     ++		struct strbuf head = STRBUF_INIT;
     ++		const char *target;
     ++
     ++		strbuf_addf(&head, "refs/remotes/%s/HEAD", item->string);
     ++		target = refs_resolve_ref_unsafe(refs, head.buf,
     ++						 RESOLVE_REF_NO_RECURSE,
     ++						 NULL, NULL);
     ++		if (target && starts_with(target, "refs/remotes/"))
     ++			string_list_insert(out, target);
     ++		strbuf_release(&head);
     ++	}
     ++}
     ++
      +static void collect_forked_set(int argc, const char **argv,
     ++			       struct string_list *protected_default_refs,
      +			       struct string_list *out)
       {
       	struct string_list remote_names = STRING_LIST_INIT_NODUP;
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      -	for_each_string_list_item(item, &out)
      -		puts(item->string);
      +	string_list_sort(out);
     ++
     ++	if (protected_default_refs)
     ++		collect_default_branch_refs(&remote_names, protected_default_refs);
       
       	string_list_clear(&remote_names, 0);
       	string_list_clear(&tracking_refs, 0);
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +	if (!argc)
      +		die(_("--forked requires at least one <remote>"));
      +
     -+	collect_forked_set(argc, argv, &out);
     ++	collect_forked_set(argc, argv, NULL, &out);
      +	for_each_string_list_item(item, &out)
      +		puts(item->string);
      +
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +				 int quiet)
      +{
      +	struct string_list candidates = STRING_LIST_INIT_DUP;
     ++	struct string_list protected_default_refs = STRING_LIST_INIT_DUP;
      +	struct strvec deletable = STRVEC_INIT;
      +	struct string_list_item *item;
      +	int n_not_merged = 0;
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +	if (!argc)
      +		die(_("--prune-merged requires at least one <remote>"));
      +
     -+	collect_forked_set(argc, argv, &candidates);
     ++	collect_forked_set(argc, argv, &protected_default_refs, &candidates);
      +
      +	for_each_string_list_item(item, &candidates) {
      +		const char *short_name = item->string;
      +		struct strbuf full = STRBUF_INIT;
      +		struct branch *branch;
      +		const char *push_ref;
     ++		const char *upstream;
      +
      +		strbuf_addf(&full, "refs/heads/%s", short_name);
      +		if (branch_checked_out(full.buf)) {
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +		strbuf_release(&full);
      +
      +		branch = branch_get(short_name);
     ++		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
     ++		if (upstream &&
     ++		    string_list_has_string(&protected_default_refs, upstream)) {
     ++			const char *leaf = strrchr(upstream, '/');
     ++			if (leaf && !strcmp(leaf + 1, short_name))
     ++				continue;
     ++		}
     ++
      +		push_ref = branch ? branch_get_push(branch, NULL) : NULL;
      +		if (!push_ref)
      +			continue;
      +		if (refs_ref_exists(get_main_ref_store(the_repository),
      +				    push_ref))
      +			continue;
     ++		if (string_list_has_string(&protected_default_refs, push_ref))
     ++			continue;
      +
      +		strvec_push(&deletable, short_name);
      +	}
     @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref,
      +
      +	strvec_clear(&deletable);
      +	string_list_clear(&candidates, 0);
     ++	string_list_clear(&protected_default_refs, 0);
      +	return ret;
      +}
      +
     @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>'
      +	test_when_finished "rm -rf pm-pushdiff" &&
      +	git clone pm-upstream pm-pushdiff &&
      +	git -C pm-pushdiff config push.default current &&
     -+	git -C pm-pushdiff branch --track topic-a origin/main &&
     ++	git -C pm-pushdiff branch --track topic-a origin/one &&
      +
      +	git -C pm-pushdiff branch --force --prune-merged origin &&
      +
      +	test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
      +'
     ++
     ++test_expect_success '--prune-merged spares the local default branch' '
     ++	test_when_finished "rm -rf pm-default" &&
     ++	git clone pm-upstream pm-default &&
     ++	git -C pm-default config push.default current &&
     ++	git -C pm-default checkout --detach &&
     ++	git -C pm-default branch --prune-merged origin &&
     ++	git -C pm-default rev-parse --verify refs/heads/main
     ++'
     ++
     ++test_expect_success '--prune-merged protects only the default branch by name, not by upstream' '
     ++	test_when_finished "rm -rf pm-default-alias" &&
     ++	git clone pm-upstream pm-default-alias &&
     ++	git -C pm-default-alias config push.default current &&
     ++	git -C pm-default-alias branch --track trunk origin/main &&
     ++	git -C pm-default-alias checkout --detach &&
     ++	git -C pm-default-alias branch --force --prune-merged origin &&
     ++	git -C pm-default-alias rev-parse --verify refs/heads/main &&
     ++	test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
     ++'
     ++
     ++test_expect_success '--prune-merged spares branches whose push ref is the default branch' '
     ++	test_when_finished "rm -rf pm-pushdefault" &&
     ++	git clone pm-upstream pm-pushdefault &&
     ++	git -C pm-pushdefault branch --track topic origin/one &&
     ++	git -C pm-pushdefault config --add remote.origin.push refs/heads/topic:refs/heads/main &&
     ++	git -C pm-pushdefault update-ref -d refs/remotes/origin/one &&
     ++	git -C pm-pushdefault update-ref -d refs/remotes/origin/main &&
     ++	git -C pm-pushdefault checkout --detach &&
     ++	git -C pm-pushdefault branch --prune-merged origin &&
     ++	git -C pm-pushdefault rev-parse --verify refs/heads/topic
     ++'
      +
       test_done
 4:  938bf7c794 = 4:  98cfdb87d2 fetch: add --prune-merged
 5:  b2e7c97298 ! 5:  c645526bb5 branch: add branch.<name>.pruneMerged opt-out
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
      +		struct strbuf key = STRBUF_INIT;
       		struct branch *branch;
       		const char *push_ref;
     + 		const char *upstream;
      +		int opt_out = 0;
       
       		strbuf_addf(&full, "refs/heads/%s", short_name);
     @@ 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,
     + 		if (upstream &&
     + 		    string_list_has_string(&protected_default_refs, upstream)) {
     + 			const char *leaf = strrchr(upstream, '/');
     +-			if (leaf && !strcmp(leaf + 1, short_name))
     ++			if (leaf && !strcmp(leaf + 1, short_name)) {
     ++				strbuf_release(&key);
     + 				continue;
     ++			}
     + 		}
       
     - 		branch = branch_get(short_name);
       		push_ref = branch ? branch_get_push(branch, NULL) : NULL;
      -		if (!push_ref)
      +		if (!push_ref) {
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
      +			strbuf_release(&key);
      +			continue;
      +		}
     ++		if (string_list_has_string(&protected_default_refs, push_ref)) {
     ++			strbuf_release(&key);
     + 			continue;
     +-		if (string_list_has_string(&protected_default_refs, push_ref))
     ++		}
      +
      +		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
      +		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       	}
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged deletes when push ref differs from upstream'
     - 	test_must_fail git -C pm-pushdiff rev-parse --verify refs/heads/topic-a
     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul
     + 	git -C pm-pushdefault rev-parse --verify refs/heads/topic
       '
       
      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
 6:  6462642cd0 ! 6:  690242d89b branch: add --all-remotes flag
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
       static void parse_forked_args(int argc, const char **argv,
       			      struct string_list *remote_names,
       			      struct string_list *tracking_refs)
     -@@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
     - 	return 0;
     +@@ builtin/branch.c: static void collect_default_branch_refs(const struct string_list *remote_names,
     + 	}
       }
       
      -static void collect_forked_set(int argc, const char **argv,
      +static void collect_forked_set(int argc, const char **argv, int all_remotes,
     + 			       struct string_list *protected_default_refs,
       			       struct string_list *out)
       {
     - 	struct string_list remote_names = STRING_LIST_INIT_NODUP;
      @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
       	};
       
     @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv,
      +	if (!argc && !all_remotes)
      +		die(_("--forked requires at least one <remote> or --all-remotes"));
       
     --	collect_forked_set(argc, argv, &out);
     -+	collect_forked_set(argc, argv, all_remotes, &out);
     +-	collect_forked_set(argc, argv, NULL, &out);
     ++	collect_forked_set(argc, argv, all_remotes, NULL, &out);
       	for_each_string_list_item(item, &out)
       		puts(item->string);
       
     @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
      +				 int all_remotes, int force, int quiet)
       {
       	struct string_list candidates = STRING_LIST_INIT_DUP;
     - 	struct strvec deletable = STRVEC_INIT;
     + 	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,
       	int n_not_merged = 0;
       	int ret = 0;
     @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
      +	if (!argc && !all_remotes)
      +		die(_("--prune-merged requires at least one <remote> or --all-remotes"));
       
     --	collect_forked_set(argc, argv, &candidates);
     -+	collect_forked_set(argc, argv, all_remotes, &candidates);
     +-	collect_forked_set(argc, argv, &protected_default_refs, &candidates);
     ++	collect_forked_set(argc, argv, all_remotes, &protected_default_refs,
     ++			   &candidates);
       
       	for_each_string_list_item(item, &candidates) {
       		const char *short_name = item->string;

-- 
gitgitgadget