Subject: [PATCH] fetch: add fetch.pruneLocalBranches config

From: "Harald Nordgren via GitGitGadget" <[email-masked]>
Date: Fri, 01 May 2026 21:35:37 +0000

From: Harald Nordgren <[email-masked]>

Introduce a tri-state config option that, when --prune (or
fetch.prune / remote.<name>.prune) removes a remote-tracking
ref, also deletes local branches whose configured upstream is
that ref.

Values:
- false (default): no change in behavior.
- safe: delete only if the local tip is reachable from the
  upstream tip, preserving any unpushed work.
- force: delete unconditionally; recoverable only via reflog.

The currently checked-out branch is always preserved.

Signed-off-by: Harald Nordgren <[email-masked]>
---
    fetch: add fetch.pruneBranches config

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

 Documentation/config/fetch.adoc  |  39 +++++++
 Documentation/config/remote.adoc |   7 ++
 Documentation/fetch-options.adoc |   9 ++
 Documentation/git-fetch.adoc     |   6 ++
 builtin/fetch.c                  | 172 ++++++++++++++++++++++++++++++-
 remote.c                         |  16 +++
 remote.h                         |  10 ++
 t/t5510-fetch.sh                 |  84 +++++++++++++++
 8 files changed, 339 insertions(+), 4 deletions(-)

diff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc
index cd40db0cad..5a60507a84 100644
--- a/Documentation/config/fetch.adoc
+++ b/Documentation/config/fetch.adoc
@@ -50,6 +50,45 @@
 	refs. See also `remote.<name>.pruneTags` and the PRUNING
 	section of linkgit:git-fetch[1].
 
+`fetch.pruneBranches`::
+	When set in addition to `fetch.prune` (or `--prune`), also
+	delete local branches whose configured upstream
+	(`branch.<name>.merge`) is one of the remote-tracking refs
+	just removed by pruning. This is useful for cleaning up topic
+	branches whose upstream counterpart has been merged and then
+	removed. The same effect can be requested per-invocation with
+	`--prune-branches[=<mode>]`, or per-remote with
+	`remote.<name>.pruneBranches`.
++
+The currently checked-out branch (in any worktree) is never
+deleted. The value is one of:
++
+--
+`false` (the default);;
+	Do not delete any local branches. Equivalent to leaving
+	the option unset.
+`safe`;;
+	Delete a local branch only if its tip is an ancestor of
+	the upstream remote-tracking ref's last-known position.
+	In other words, only delete the branch if it contains no
+	commits that the upstream did not also have at the moment
+	it was deleted. This catches the common case of a branch
+	that was pushed and then squash- or rebase-merged
+	upstream (the local branch has no extra commits beyond
+	what was pushed), but preserves any branch with unpushed
+	local work.
+`force`;;
+	Delete the local branch unconditionally, even if it
+	contains unpushed commits. Use with care: if a remote
+	branch is deleted for any reason other than that its
+	contents were merged, the corresponding local commits
+	will only be retrievable through the reflog.
+--
++
+This option has no effect unless pruning is also enabled, since
+local branches are only considered for deletion when their
+upstream remote-tracking ref is being pruned in the same fetch.
+
 `fetch.all`::
 	If true, fetch will attempt to update all available remotes.
 	This behavior can be overridden by passing `--no-all` or by
diff --git a/Documentation/config/remote.adoc b/Documentation/config/remote.adoc
index 91e46f66f5..60fd5841c6 100644
--- a/Documentation/config/remote.adoc
+++ b/Documentation/config/remote.adoc
@@ -87,6 +87,13 @@ remote.<name>.pruneTags::
 See also `remote.<name>.prune` and the PRUNING section of
 linkgit:git-fetch[1].
 
+remote.<name>.pruneBranches::
+	When pruning is active for this remote and this is set to `safe`
+	or `force`, also delete local branches whose upstream
+	remote-tracking ref is being pruned. Overrides
+	`fetch.pruneBranches` settings, if any. See `fetch.pruneBranches`
+	for the meaning of the values.
+
 remote.<name>.promisor::
 	When set to true, this remote will be used to fetch promisor
 	objects.
diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc
index 81a9d7f9bb..0764f67cc3 100644
--- a/Documentation/fetch-options.adoc
+++ b/Documentation/fetch-options.adoc
@@ -185,6 +185,15 @@ See the PRUNING section below for more details.
 +
 See the PRUNING section below for more details.
 
+`--prune-branches[=(safe|force)]`::
+	When pruning, also delete local branches whose configured
+	upstream (`branch.<name>.merge`) is one of the remote-tracking
+	refs being pruned. With no value or `safe`, refuse to delete a
+	branch with unpushed commits; with `force`, delete it
+	regardless. The currently checked-out branch is never
+	deleted. See `fetch.pruneBranches` in linkgit:git-config[1] for
+	details.
+
 endif::git-pull[]
 
 ifndef::git-pull[]
diff --git a/Documentation/git-fetch.adoc b/Documentation/git-fetch.adoc
index db03541915..a50b9672a1 100644
--- a/Documentation/git-fetch.adoc
+++ b/Documentation/git-fetch.adoc
@@ -179,6 +179,12 @@ It's reasonable to e.g. configure `fetch.pruneTags=true` in
 run, without making every invocation of `git fetch` without `--prune`
 an error.
 
+Local branches whose upstream remote-tracking ref is being pruned can
+also be deleted automatically with `--prune-branches[=<mode>]` (or its
+config equivalents `fetch.pruneBranches` and `remote.<name>.pruneBranches`).
+See linkgit:git-config[1] for the data-loss tradeoff between the
+`safe` and `force` modes.
+
 Pruning tags with `--prune-tags` also works when fetching a URL
 instead of a named remote. These will all prune tags not found on
 origin:
diff --git a/builtin/fetch.c b/builtin/fetch.c
index a22c319467..c6c2f00be0 100644
--- a/builtin/fetch.c
+++ b/builtin/fetch.c
@@ -82,6 +82,21 @@ static int prune = -1; /* unspecified */
 static int prune_tags = -1; /* unspecified */
 #define PRUNE_TAGS_BY_DEFAULT 0 /* do we prune tags by default? */
 
+static int prune_branches = PRUNE_BRANCHES_UNSPECIFIED;
+
+static int parse_prune_branches_opt(const struct option *opt,
+				    const char *arg, int unset)
+{
+	int *v = opt->value;
+	if (unset)
+		*v = PRUNE_BRANCHES_OFF;
+	else if (arg)
+		*v = parse_prune_branches_value(opt->long_name, arg);
+	else
+		*v = PRUNE_BRANCHES_SAFE;
+	return 0;
+}
+
 static int append, dry_run, force, keep, update_head_ok;
 static int write_fetch_head = 1;
 static int verbosity, deepen_relative, set_upstream, refetch;
@@ -105,6 +120,7 @@ struct fetch_config {
 	int all;
 	int prune;
 	int prune_tags;
+	enum prune_branches_mode prune_branches;
 	int show_forced_updates;
 	int recurse_submodules;
 	int parallel;
@@ -131,6 +147,11 @@ static int git_fetch_config(const char *k, const char *v,
 		return 0;
 	}
 
+	if (!strcmp(k, "fetch.prunebranches")) {
+		fetch_config->prune_branches = parse_prune_branches_value(k, v);
+		return 0;
+	}
+
 	if (!strcmp(k, "fetch.showforcedupdates")) {
 		fetch_config->show_forced_updates = git_config_bool(k, v);
 		return 0;
@@ -1445,7 +1466,8 @@ out:
 static int prune_refs(struct display_state *display_state,
 		      struct refspec *rs,
 		      struct ref_transaction *transaction,
-		      struct ref *ref_map)
+		      struct ref *ref_map,
+		      struct ref **stale_refs_out)
 {
 	int result = 0;
 	struct ref *ref, *stale_refs = get_stale_heads(rs, ref_map);
@@ -1487,7 +1509,126 @@ static int prune_refs(struct display_state *display_state,
 cleanup:
 	string_list_clear(&refnames, 0);
 	strbuf_release(&err);
-	free_refs(stale_refs);
+	if (!result && stale_refs_out)
+		*stale_refs_out = stale_refs;
+	else
+		free_refs(stale_refs);
+	return result;
+}
+
+struct prune_branches_cb {
+	struct string_list *pruned_refs;
+	struct string_list *to_delete;
+	struct string_list *skipped_unmerged;
+	enum prune_branches_mode mode;
+};
+
+static int collect_branches_to_prune(const struct reference *ref, void *cb_data)
+{
+	struct prune_branches_cb *cb = cb_data;
+	const char *short_name = ref->name;
+	char *full_ref = xstrfmt("refs/heads/%s", short_name);
+	const char *upstream;
+	struct string_list_item *pruned;
+	int result = 0;
+
+	if (ref->flags & REF_ISSYMREF)
+		goto out;
+	if (branch_checked_out(full_ref))
+		goto out;
+
+	upstream = branch_get_upstream(branch_get(short_name), NULL);
+	if (!upstream)
+		goto out;
+
+	pruned = string_list_lookup(cb->pruned_refs, upstream);
+	if (!pruned)
+		goto out;
+
+	if (cb->mode == PRUNE_BRANCHES_SAFE) {
+		struct commit *local = lookup_commit_reference(the_repository,
+							       ref->oid);
+		struct commit *up = lookup_commit_reference(the_repository,
+							    pruned->util);
+		int reachable = local && up &&
+			repo_in_merge_bases(the_repository, local, up);
+
+		if (reachable < 0) {
+			result = -1;
+			goto out;
+		}
+		if (!reachable) {
+			string_list_append(cb->skipped_unmerged, short_name);
+			goto out;
+		}
+	}
+
+	string_list_append(cb->to_delete, full_ref);
+
+out:
+	free(full_ref);
+	return result;
+}
+
+static int do_prune_branches(struct display_state *display_state,
+			     struct ref *stale_refs,
+			     enum prune_branches_mode mode)
+{
+	struct string_list pruned_refs = STRING_LIST_INIT_NODUP;
+	struct string_list to_delete = STRING_LIST_INIT_DUP;
+	struct string_list skipped_unmerged = STRING_LIST_INIT_DUP;
+	struct prune_branches_cb cb = {
+		.pruned_refs = &pruned_refs,
+		.to_delete = &to_delete,
+		.skipped_unmerged = &skipped_unmerged,
+		.mode = mode,
+	};
+	struct ref *ref;
+	struct string_list_item *item;
+	int result = 0;
+
+	if (!stale_refs)
+		return 0;
+
+	for (ref = stale_refs; ref; ref = ref->next)
+		string_list_append(&pruned_refs, ref->name)->util = &ref->new_oid;
+	string_list_sort(&pruned_refs);
+
+	if (refs_for_each_branch_ref(get_main_ref_store(the_repository),
+				     collect_branches_to_prune, &cb)) {
+		result = -1;
+		goto cleanup;
+	}
+
+	if (!dry_run && to_delete.nr)
+		result = refs_delete_refs(get_main_ref_store(the_repository),
+					  "fetch: prune branches",
+					  &to_delete, REF_NO_DEREF);
+
+	if (verbosity >= 0) {
+		const struct object_id *zero = null_oid(the_repository->hash_algo);
+		for_each_string_list_item(item, &to_delete) {
+			const char *short_name;
+			if (skip_prefix(item->string, "refs/heads/", &short_name))
+				display_ref_update(display_state, '-',
+						   _("[deleted local]"), NULL,
+						   _("(none)"), short_name,
+						   zero, zero,
+						   transport_summary_width(NULL));
+		}
+	}
+	for_each_string_list_item(item, &skipped_unmerged)
+		warning(_("not deleting local branch '%s' that is not "
+			  "fully merged into its upstream;\n"
+			  "         set fetch.pruneBranches=force to "
+			  "delete anyway, or delete manually with "
+			  "'git branch -D %s'"),
+			item->string, item->string);
+
+cleanup:
+	string_list_clear(&pruned_refs, 0);
+	string_list_clear(&to_delete, 0);
+	string_list_clear(&skipped_unmerged, 0);
 	return result;
 }
 
@@ -1945,19 +2086,28 @@ static int do_fetch(struct transport *transport,
 	if (tags == TAGS_DEFAULT && autotags)
 		transport_set_option(transport, TRANS_OPT_FOLLOWTAGS, "1");
 	if (prune) {
+		struct ref *stale_refs = NULL;
+		struct ref **stale_refs_out = prune_branches != PRUNE_BRANCHES_OFF
+			? &stale_refs : NULL;
 		/*
 		 * We only prune based on refspecs specified
 		 * explicitly (via command line or configuration); we
 		 * don't care whether --tags was specified.
 		 */
 		if (rs->nr) {
-			retcode = prune_refs(&display_state, rs, transaction, ref_map);
+			retcode = prune_refs(&display_state, rs, transaction,
+					     ref_map, stale_refs_out);
 		} else {
 			retcode = prune_refs(&display_state, &transport->remote->fetch,
-					     transaction, ref_map);
+					     transaction, ref_map, stale_refs_out);
 		}
 		if (retcode != 0)
 			retcode = 1;
+		else if (stale_refs &&
+			 do_prune_branches(&display_state, stale_refs,
+					   prune_branches))
+			retcode = 1;
+		free_refs(stale_refs);
 	}
 
 	/*
@@ -2419,6 +2569,16 @@ static int fetch_one(struct remote *remote, int argc, const char **argv,
 			prune_tags = PRUNE_TAGS_BY_DEFAULT;
 	}
 
+	if (prune_branches == PRUNE_BRANCHES_UNSPECIFIED) {
+		/* no command line request */
+		if (remote->prune_branches >= 0)
+			prune_branches = remote->prune_branches;
+		else if (config->prune_branches >= 0)
+			prune_branches = config->prune_branches;
+		else
+			prune_branches = PRUNE_BRANCHES_OFF;
+	}
+
 	maybe_prune_tags = prune_tags_ok && prune_tags;
 	if (maybe_prune_tags && remote_via_config)
 		refspec_append(&remote->fetch, TAG_REFSPEC);
@@ -2469,6 +2629,7 @@ int cmd_fetch(int argc,
 		.display_format = DISPLAY_FORMAT_FULL,
 		.prune = -1,
 		.prune_tags = -1,
+		.prune_branches = PRUNE_BRANCHES_UNSPECIFIED,
 		.show_forced_updates = 1,
 		.recurse_submodules = RECURSE_SUBMODULES_DEFAULT,
 		.parallel = 1,
@@ -2520,6 +2681,9 @@ int cmd_fetch(int argc,
 			 N_("prune remote-tracking branches no longer on remote")),
 		OPT_BOOL('P', "prune-tags", &prune_tags,
 			 N_("prune local tags no longer on remote and clobber changed tags")),
+		OPT_CALLBACK_F(0, "prune-branches", &prune_branches, N_("mode"),
+			       N_("delete local branches whose upstream was pruned ('safe' or 'force')"),
+			       PARSE_OPT_OPTARG, parse_prune_branches_opt),
 		OPT_CALLBACK_F(0, "recurse-submodules", &recurse_submodules_cli, N_("on-demand"),
 			    N_("control recursive fetching of submodules"),
 			    PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules),
diff --git a/remote.c b/remote.c
index a664cd166a..1e2b4803e7 100644
--- a/remote.c
+++ b/remote.c
@@ -148,6 +148,7 @@ static struct remote *make_remote(struct remote_state *remote_state,
 	CALLOC_ARRAY(ret, 1);
 	ret->prune = -1;  /* unspecified */
 	ret->prune_tags = -1;  /* unspecified */
+	ret->prune_branches = -1;  /* unspecified */
 	ret->name = xstrndup(name, len);
 	refspec_init_push(&ret->push);
 	refspec_init_fetch(&ret->fetch);
@@ -423,6 +424,19 @@ out:
 }
 #endif /* WITH_BREAKING_CHANGES */
 
+int parse_prune_branches_value(const char *k, const char *v)
+{
+	if (v) {
+		if (!strcasecmp(v, "safe"))
+			return PRUNE_BRANCHES_SAFE;
+		if (!strcasecmp(v, "force"))
+			return PRUNE_BRANCHES_FORCE;
+	}
+	if (git_parse_maybe_bool(v) == 0)
+		return PRUNE_BRANCHES_OFF;
+	die(_("invalid value for '%s': '%s'"), k, v);
+}
+
 static int handle_config(const char *key, const char *value,
 			 const struct config_context *ctx, void *cb)
 {
@@ -507,6 +521,8 @@ static int handle_config(const char *key, const char *value,
 		remote->prune = git_config_bool(key, value);
 	else if (!strcmp(subkey, "prunetags"))
 		remote->prune_tags = git_config_bool(key, value);
+	else if (!strcmp(subkey, "prunebranches"))
+		remote->prune_branches = parse_prune_branches_value(key, value);
 	else if (!strcmp(subkey, "url")) {
 		if (!value)
 			return config_error_nonbool(key);
diff --git a/remote.h b/remote.h
index fc052945ee..5b750c8229 100644
--- a/remote.h
+++ b/remote.h
@@ -28,6 +28,15 @@ enum {
 #endif /* WITH_BREAKING_CHANGES */
 };
 
+enum prune_branches_mode {
+	PRUNE_BRANCHES_UNSPECIFIED = -1,
+	PRUNE_BRANCHES_OFF = 0,
+	PRUNE_BRANCHES_SAFE,
+	PRUNE_BRANCHES_FORCE,
+};
+
+int parse_prune_branches_value(const char *k, const char *v);
+
 struct rewrite {
 	const char *base;
 	size_t baselen;
@@ -102,6 +111,7 @@ struct remote {
 	int mirror;
 	int prune;
 	int prune_tags;
+	int prune_branches;
 
 	/**
 	 * The configured helper programs to run on the remote side, for
diff --git a/t/t5510-fetch.sh b/t/t5510-fetch.sh
index 6fe21e2b3a..5a2ff40132 100755
--- a/t/t5510-fetch.sh
+++ b/t/t5510-fetch.sh
@@ -386,6 +386,90 @@ test_expect_success REFFILES 'fetch --prune fails to delete branches' '
 	)
 '
 
+test_expect_success 'fetch.pruneBranches: setup parent' '
+	git init -b main prune-branches-parent &&
+	test_commit -C prune-branches-parent base
+'
+
+test_expect_success 'fetch.pruneBranches=safe deletes merged local branch' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-safe &&
+	git -C prune-branches-safe checkout -b doomed --track origin/doomed &&
+	git -C prune-branches-safe checkout -b stay &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-safe -c fetch.pruneBranches=safe fetch --prune origin &&
+	test_must_fail git -C prune-branches-safe rev-parse refs/remotes/origin/doomed &&
+	test_must_fail git -C prune-branches-safe rev-parse refs/heads/doomed
+'
+
+test_expect_success 'fetch.pruneBranches=safe keeps unmerged local branch' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-safe-unmerged &&
+	git -C prune-branches-safe-unmerged checkout -b doomed --track origin/doomed &&
+	test_commit -C prune-branches-safe-unmerged local-only &&
+	git -C prune-branches-safe-unmerged checkout -b stay &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-safe-unmerged -c fetch.pruneBranches=safe fetch --prune origin 2>err &&
+	test_must_fail git -C prune-branches-safe-unmerged rev-parse refs/remotes/origin/doomed &&
+	git -C prune-branches-safe-unmerged rev-parse refs/heads/doomed &&
+	test_grep "not fully merged" err
+'
+
+test_expect_success 'fetch.pruneBranches=force deletes unmerged local branch' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-force &&
+	git -C prune-branches-force checkout -b doomed --track origin/doomed &&
+	test_commit -C prune-branches-force local-only-force &&
+	git -C prune-branches-force checkout -b stay &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-force -c fetch.pruneBranches=force fetch --prune origin &&
+	test_must_fail git -C prune-branches-force rev-parse refs/remotes/origin/doomed &&
+	test_must_fail git -C prune-branches-force rev-parse refs/heads/doomed
+'
+
+test_expect_success 'fetch.pruneBranches=force never deletes checked-out branch' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-checked-out &&
+	git -C prune-branches-checked-out checkout -b doomed --track origin/doomed &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-checked-out -c fetch.pruneBranches=force fetch --prune origin &&
+	test_must_fail git -C prune-branches-checked-out rev-parse refs/remotes/origin/doomed &&
+	git -C prune-branches-checked-out rev-parse refs/heads/doomed
+'
+
+test_expect_success '--prune-branches deletes merged local branch' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-cli &&
+	git -C prune-branches-cli checkout -b doomed --track origin/doomed &&
+	git -C prune-branches-cli checkout -b stay &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-cli fetch --prune --prune-branches origin &&
+	test_must_fail git -C prune-branches-cli rev-parse refs/heads/doomed
+'
+
+test_expect_success '--no-prune-branches overrides fetch.pruneBranches' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-no-cli &&
+	git -C prune-branches-no-cli checkout -b doomed --track origin/doomed &&
+	git -C prune-branches-no-cli checkout -b stay &&
+	git -C prune-branches-no-cli config fetch.pruneBranches force &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-no-cli fetch --prune --no-prune-branches origin &&
+	git -C prune-branches-no-cli rev-parse refs/heads/doomed
+'
+
+test_expect_success 'remote.<name>.pruneBranches overrides fetch.pruneBranches' '
+	git -C prune-branches-parent branch doomed base &&
+	git clone prune-branches-parent prune-branches-per-remote &&
+	git -C prune-branches-per-remote checkout -b doomed --track origin/doomed &&
+	git -C prune-branches-per-remote checkout -b stay &&
+	git -C prune-branches-per-remote config fetch.pruneBranches force &&
+	git -C prune-branches-per-remote config remote.origin.pruneBranches false &&
+	git -C prune-branches-parent branch -D doomed &&
+	git -C prune-branches-per-remote fetch --prune origin &&
+	git -C prune-branches-per-remote rev-parse refs/heads/doomed
+'
+
 test_expect_success 'fetch --atomic works with a single branch' '
 	test_when_finished "rm -rf atomic" &&

base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
-- 
gitgitgadget