[PATCH v7 0/5] branch: prune-merged
COLD55d
From: Harald Nordgren via GitGitGadget <hidden>
Date: 2026-05-12 08:23:54
Revision v7 of 10 in this series.
Revisions (10)
* --prune-merged now checks if a branch is merged into its own upstream first. If the upstream is gone, it checks against the remote's default branch instead. If neither exists, the branch is refused (use --force to delete anyway). 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 | 33 +++ builtin/branch.c | 332 +++++++++++++++++++++++++++++-- t/t3200-branch.sh | 280 ++++++++++++++++++++++++++ 4 files changed, 635 insertions(+), 17 deletions(-) base-commit: 29bd7ed5127255713c1ac2f43b7c6f257d7b4594 Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v7 Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v7 Pull-Request: https://github.com/git/git/pull/2285 Range-diff vs v6: 1: fb9817b220 = 1: 22fa8515df branch: add --forked <remote> 2: 42a2f93d44 = 2: b443f0f367 branch: let delete_branches warn instead of error on bulk refusal 3: 604ecb8965 ! 3: a245009893 branch: add --prune-merged <remote> @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi + been pruned upstream. ++ +As a safety check, branches with commits not yet integrated into -+the remote's default branch are refused. With `--force` (or `-f`), -+delete them regardless. The currently checked-out branch in any -+worktree is always preserved. ++their upstream remote-tracking branch are refused; if the upstream ++itself is gone, the remote's default branch is consulted instead. ++With `--force` (or `-f`), delete refused branches regardless. The ++currently checked-out branch in any worktree is always preserved. + `-v`:: `-vv`:: @@ builtin/branch.c #include "column.h" #include "utf8.h" #include "ref-filter.h" +@@ builtin/branch.c: static const char *branch_get_color(enum color_branch ix) + } + + static int branch_merged(int kind, const char *name, +- struct commit *rev, struct commit *head_rev) ++ struct commit *rev, struct commit *head_rev, ++ int no_head_fallback) + { + /* + * This checks whether the merge bases of branch and HEAD (or +@@ builtin/branch.c: static int branch_merged(int kind, const char *name, + &oid, NULL)) != NULL) + reference_rev = lookup_commit_reference(the_repository, + &oid); ++ ++ if (!reference_rev && no_head_fallback && upstream && ++ starts_with(upstream, "refs/remotes/")) { ++ const char *remote_name = upstream + strlen("refs/remotes/"); ++ const char *slash = strchr(remote_name, '/'); ++ if (slash) { ++ struct strbuf head_ref = STRBUF_INIT; ++ strbuf_add(&head_ref, "refs/remotes/", strlen("refs/remotes/")); ++ strbuf_add(&head_ref, remote_name, slash - remote_name); ++ strbuf_addstr(&head_ref, "/HEAD"); ++ if (refs_resolve_ref_unsafe(get_main_ref_store(the_repository), ++ head_ref.buf, ++ RESOLVE_REF_READING, ++ &oid, NULL)) ++ reference_rev = lookup_commit_reference(the_repository, ++ &oid); ++ strbuf_release(&head_ref); ++ } ++ } + } +- if (!reference_rev) ++ if (!reference_rev) { ++ if (no_head_fallback) { ++ free(reference_name_to_free); ++ return 0; ++ } + reference_rev = head_rev; ++ } + + merged = reference_rev ? repo_in_merge_bases(the_repository, rev, + reference_rev) : 0; +@@ builtin/branch.c: static int branch_merged(int kind, const char *name, + * any of the following code, but during the transition period, + * a gentle reminder is in order. + */ +- if (head_rev != reference_rev) { ++ if (!no_head_fallback && head_rev != reference_rev) { + int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0; + if (expect < 0) + exit(128); @@ builtin/branch.c: static int branch_merged(int kind, const char *name, static int check_branch_commit(const char *branchname, const char *refname, const struct object_id *oid, struct commit *head_rev, -+ struct commit *head_rev_override, -+ int use_head_rev_override, ++ int no_head_fallback, int kinds, int force, int warn_only, int *n_not_merged) { - struct commit *rev = lookup_commit_reference(the_repository, oid); -+ int merged; -+ - if (!force && !rev) { +@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname, error(_("couldn't look up commit object for '%s'"), refname); return -1; } - if (!force && !branch_merged(kinds, branchname, rev, head_rev)) { -+ if (use_head_rev_override) { -+ if (!head_rev_override) -+ return 0; -+ merged = repo_in_merge_bases(the_repository, rev, -+ head_rev_override); -+ if (merged < 0) -+ exit(128); -+ } else { -+ merged = branch_merged(kinds, branchname, rev, head_rev); -+ } -+ if (!force && !merged) { ++ if (!force && !branch_merged(kinds, branchname, rev, head_rev, ++ no_head_fallback)) { if (warn_only) { warning(_("the branch '%s' is not fully merged"), branchname); @@ builtin/branch.c: static void delete_branch_config(const char *branchname) -static int delete_branches(int argc, const char **argv, int force, int kinds, +static int delete_branches(int argc, const char **argv, -+ struct commit **head_rev_overrides, ++ int no_head_fallback, + int force, int kinds, int quiet, int warn_only, int *n_not_merged) { @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo - check_branch_commit(bname.buf, name, &oid, head_rev, kinds, - force, warn_only, n_not_merged)) { + check_branch_commit(bname.buf, name, &oid, head_rev, -+ head_rev_overrides ? head_rev_overrides[i] : NULL, -+ !!head_rev_overrides, ++ no_head_fallback, + kinds, force, warn_only, n_not_merged)) { if (!warn_only) ret = 1; @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, return 0; } -+static struct commit *resolve_remote_head(const char *remote_name) -+{ -+ struct ref_store *refs = get_main_ref_store(the_repository); -+ struct strbuf head_ref = STRBUF_INIT; -+ struct object_id oid; -+ struct commit *commit = NULL; -+ -+ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote_name); -+ if (refs_resolve_ref_unsafe(refs, head_ref.buf, RESOLVE_REF_READING, -+ &oid, NULL)) -+ commit = lookup_commit_reference(the_repository, &oid); -+ strbuf_release(&head_ref); -+ return commit; -+} -+ +static int prune_merged_branches(int argc, const char **argv, int force, + 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 commit **head_rev_overrides = NULL; -+ size_t alloc = 0; + struct string_list_item *item; + int n_not_merged = 0; + int ret = 0; @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, + struct branch *branch; + const char *push_ref; + const char *upstream; -+ const char *remote_name; -+ const char *slash; + + 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, + if (string_list_has_string(&protected_default_refs, push_ref)) + continue; + -+ ALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc); -+ remote_name = push_ref + strlen("refs/remotes/"); -+ slash = strchr(remote_name, '/'); -+ if (slash) { -+ char *name = xstrndup(remote_name, slash - remote_name); -+ head_rev_overrides[deletable.nr] = resolve_remote_head(name); -+ free(name); -+ } else { -+ head_rev_overrides[deletable.nr] = NULL; -+ } + strvec_push(&deletable, short_name); + } + + if (deletable.nr) + ret = delete_branches(deletable.nr, deletable.v, -+ head_rev_overrides, force, ++ 1, force, + FILTER_REFS_BRANCHES, quiet, + 1, &n_not_merged); + @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, + n_not_merged); + + strvec_clear(&deletable); -+ free(head_rev_overrides); + string_list_clear(&candidates, 0); + string_list_clear(&protected_default_refs, 0); + return ret; @@ builtin/branch.c: int cmd_branch(int argc, if (!argc) die(_("branch name required")); - ret = delete_branches(argc, argv, delete > 1, filter.kind, -+ ret = delete_branches(argc, argv, NULL, delete > 1, filter.kind, ++ ret = delete_branches(argc, argv, 0, delete > 1, filter.kind, quiet, 0, NULL); goto out; } else if (forked) { @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <remote>' + test_must_fail git -C pm-force rev-parse --verify refs/heads/one +' + -+test_expect_success '--prune-merged measures merged-ness against <remote>/HEAD, not local HEAD' ' -+ test_when_finished "rm -rf pm-head-indep" && -+ git clone pm-upstream pm-head-indep && -+ git -C pm-head-indep branch one --track origin/one && -+ git -C pm-head-indep update-ref -d refs/remotes/origin/one && ++test_expect_success '--prune-merged falls back to remote default branch when upstream is gone' ' ++ test_when_finished "rm -rf pm-fallback" && ++ git clone pm-upstream pm-fallback && ++ git -C pm-fallback branch one --track origin/one && ++ git -C pm-fallback update-ref -d refs/remotes/origin/one && + # Detach HEAD to an unrelated commit so the candidate is not -+ # reachable from local HEAD; it is still reachable from -+ # refs/remotes/origin/HEAD, which is what should matter. -+ git -C pm-head-indep commit --allow-empty -m unrelated && -+ git -C pm-head-indep checkout --detach && -+ git -C pm-head-indep reset --hard HEAD^ && ++ # reachable from local HEAD. The upstream origin/one is now ++ # gone; the merged-ness check should fall back to ++ # refs/remotes/origin/HEAD, against which "one" is reachable. ++ git -C pm-fallback commit --allow-empty -m unrelated && ++ git -C pm-fallback checkout --detach && ++ git -C pm-fallback reset --hard HEAD^ && + -+ git -C pm-head-indep branch --prune-merged origin && ++ git -C pm-fallback branch --prune-merged origin && + -+ test_must_fail git -C pm-head-indep rev-parse --verify refs/heads/one ++ test_must_fail git -C pm-fallback rev-parse --verify refs/heads/one +' + -+test_expect_success '--prune-merged skips merged-ness check when <remote>/HEAD is unset' ' -+ test_when_finished "rm -rf pm-no-head" && -+ git clone pm-upstream pm-no-head && -+ git -C pm-no-head checkout -b one --track origin/one && -+ test_commit -C pm-no-head unpushed && -+ git -C pm-no-head checkout - && ++test_expect_success '--prune-merged refuses when upstream and remote default are both gone' ' ++ test_when_finished "rm -rf pm-both-gone" && ++ git clone pm-upstream pm-both-gone && ++ git -C pm-both-gone checkout -b one --track origin/one && ++ test_commit -C pm-both-gone unpushed && ++ git -C pm-both-gone checkout - && + -+ git -C pm-no-head update-ref -d refs/remotes/origin/HEAD && -+ git -C pm-no-head update-ref -d refs/remotes/origin/one && -+ git -C pm-no-head branch --prune-merged origin && ++ git -C pm-both-gone update-ref -d refs/remotes/origin/HEAD && ++ git -C pm-both-gone update-ref -d refs/remotes/origin/one && ++ git -C pm-both-gone branch --prune-merged origin 2>err && ++ test_grep "not fully merged" err && + -+ test_must_fail git -C pm-no-head rev-parse --verify refs/heads/one ++ git -C pm-both-gone rev-parse --verify refs/heads/one +' + +test_expect_success '--prune-merged never deletes the checked-out branch' ' 4: 717fc6758e ! 4: 2c3f751569 branch: add branch.<name>.pruneMerged opt-out @@ Documentation/git-branch.adoc: Each _<remote>_ may be either the name of a confi + that name has since been pruned upstream. + As a safety check, branches with commits not yet integrated into - the remote's default branch are refused. With `--force` (or `-f`), - delete them regardless. The currently checked-out branch in any --worktree is always preserved. -+worktree is always preserved, as is any branch with -+`branch.<name>.pruneMerged` set to `false`. + their upstream remote-tracking branch are refused; if the upstream + itself is gone, the remote's default branch is consulted instead. + With `--force` (or `-f`), delete refused branches regardless. The +-currently checked-out branch in any worktree is always preserved. ++currently checked-out branch in any worktree is always preserved, ++as is any branch with `branch.<name>.pruneMerged` set to `false`. `-v`:: `-vv`:: @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, struct branch *branch; const char *push_ref; const char *upstream; - const char *remote_name; - const char *slash; + 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, + } + strbuf_release(&key); - ALLOC_GROW(head_rev_overrides, deletable.nr + 1, alloc); - remote_name = push_ref + strlen("refs/remotes/"); + strvec_push(&deletable, short_name); + } ## t/t3200-branch.sh ## @@ t/t3200-branch.sh: test_expect_success '--prune-merged spares branches whose push ref is the defaul 5: be25572957 ! 5: f79707ce7c branch: add --all-remotes flag @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch> DESCRIPTION ----------- -@@ Documentation/git-branch.adoc: delete them regardless. The currently checked-out branch in any - worktree is always preserved, as is any branch with - `branch.<name>.pruneMerged` set to `false`. +@@ Documentation/git-branch.adoc: With `--force` (or `-f`), delete refused branches regardless. The + currently checked-out branch in any worktree is always preserved, + as is any branch with `branch.<name>.pruneMerged` set to `false`. +`--all-remotes`:: + With `--forked` or `--prune-merged`, act on every @@ builtin/branch.c: static void collect_forked_set(int argc, const char **argv, for_each_string_list_item(item, &out) puts(item->string); -@@ builtin/branch.c: static struct commit *resolve_remote_head(const char *remote_name) - return commit; +@@ 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, -- gitgitgadget