From: "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com>,
Johannes Sixt <j6t@kdbg.org>,
Harald Nordgren <haraldnordgren@gmail.com>
Subject: [PATCH v4 0/6] fetch: add fetch.pruneBranches config
Date: Tue, 05 May 2026 19:23:52 +0000 [thread overview]
Message-ID: <pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2285.v3.git.git.1777965747.gitgitgadget@gmail.com>
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
next prev parent reply other threads:[~2026-05-05 19:24 UTC|newest]
Thread overview: 70+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-01 21:35 [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren via GitGitGadget
2026-05-03 22:39 ` Junio C Hamano
2026-05-04 18:28 ` [PATCH] checkout: add --autostash option for branch switching Harald Nordgren
2026-05-10 1:01 ` Junio C Hamano
2026-05-05 7:14 ` [PATCH] fetch: add fetch.pruneLocalBranches config Johannes Sixt
2026-05-04 18:27 ` [PATCH v2 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-04 23:25 ` Kristoffer Haugsbakk
2026-05-04 18:27 ` [PATCH v2 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-04 18:27 ` [PATCH v2 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05 7:22 ` [PATCH v3 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` Harald Nordgren via GitGitGadget [this message]
2026-05-05 19:23 ` [PATCH v4 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05 20:48 ` Johannes Sixt
2026-05-05 22:07 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11 2:59 ` Junio C Hamano
2026-05-11 6:56 ` Harald Nordgren
2026-05-05 19:23 ` [PATCH v4 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05 19:23 ` [PATCH v4 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-07 20:14 ` [PATCH v4 0/6] fetch: add fetch.pruneBranches config Harald Nordgren
2026-05-11 6:58 ` [PATCH v5 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11 8:18 ` Junio C Hamano
2026-05-11 8:44 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11 6:58 ` [PATCH v5 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11 6:58 ` [PATCH v5 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11 9:44 ` [PATCH v6 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11 23:20 ` [PATCH v6 0/5] branch: prune-merged Junio C Hamano
2026-05-12 7:35 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12 8:23 ` [PATCH v7 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 13:53 ` Junio C Hamano
2026-05-12 17:00 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12 8:23 ` [PATCH v7 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12 8:23 ` [PATCH v7 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12 17:07 ` [PATCH v8 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-13 13:46 ` [PATCH v8 0/5] branch: prune-merged Junio C Hamano
2026-05-13 18:57 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-13 19:34 ` [PATCH v9 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-13 19:34 ` [PATCH v9 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=pull.2285.v4.git.git.1778009038.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=haraldnordgren@gmail.com \
--cc=j6t@kdbg.org \
--cc=kristofferhaugsbakk@fastmail.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox