From 77fb3683fecd46da4f24e2841b79a0a43fc88e1a Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Thu, 18 Jun 2026 19:25:27 +0000 Subject: [PATCH] branch: add --delete-merged git branch --delete-merged ... deletes the local branches that "--forked " would list, keeping only those whose tip is reachable from their configured upstream. The work has already landed on the upstream they track, so the local copy is no longer needed. Three kinds of branches are not deleted: * any branch checked out in any worktree * any branch whose upstream remote-tracking branch no longer exists, since a missing upstream is not by itself a sign of integration * any branch whose push destination equals its upstream (@{push} is the same as @{upstream}), such as a local "main" that tracks and pushes to "origin/main". Right after a pull it just looks "fully merged", so it is kept. Only branches that push somewhere other than their upstream, typically topics in a fork workflow, are candidates. A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-branch.adoc | 24 ++++++++ builtin/branch.c | 67 +++++++++++++++++++++- t/t3200-branch.sh | 105 ++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc index b0d66a6deb..f82cfa36d0 100644 --- a/Documentation/git-branch.adoc +++ b/Documentation/git-branch.adoc @@ -25,6 +25,7 @@ git branch (-m|-M) [] git branch (-c|-C) [] git branch (-d|-D) [-r] ... git branch --edit-description [] +git branch --delete-merged ... DESCRIPTION ----------- @@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode. Print the name of the current branch. In detached `HEAD` state, nothing is printed. +`--delete-merged ...`:: + Delete the local branches that `--forked` would list for the + given __ arguments, but only those whose tip is + reachable from their configured upstream. In other words, the + work on the branch has already landed on the upstream it + tracks, so the local copy is no longer needed. Several + __ patterns may be given, e.g. `git branch + --delete-merged origin/main 'feature*'`. ++ +A branch is not deleted when: ++ +-- +* its upstream remote-tracking branch no longer exists, +* it is checked out in any worktree, or +* its push destination (`@{push}`) equals its upstream + (`@{upstream}`), so it cannot be distinguished from a + branch that just looks "fully merged" right after a pull. +-- ++ +A branch whose work has not yet been merged into its upstream is +silently skipped. Delete it with `git branch -D` if you want to +remove it anyway. + `-v`:: `-vv`:: `--verbose`:: diff --git a/builtin/branch.c b/builtin/branch.c index 1d3f28e4cb..e7e4f1d27f 100644 --- a/builtin/branch.c +++ b/builtin/branch.c @@ -38,6 +38,7 @@ static const char * const builtin_branch_usage[] = { N_("git branch [] (-c | -C) [] "), N_("git branch [] [-r | -a] [--points-at]"), N_("git branch [] [-r | -a] [--format]"), + N_("git branch [] --delete-merged ..."), NULL }; @@ -714,6 +715,60 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset return 0; } +static int delete_merged_branches(int argc, const char **argv, + unsigned int flags) +{ + struct ref_store *refs = get_main_ref_store(the_repository); + struct ref_filter filter = REF_FILTER_INIT; + struct ref_array candidates = { 0 }; + struct strvec deletable = STRVEC_INIT; + int i, ret = 0; + + if (!argc) + die(_("--delete-merged requires at least one ")); + + for (i = 0; i < argc; i++) + if (ref_filter_forked_add(&filter, argv[i]) < 0) + die(_("'%s' is not a valid branch or pattern"), argv[i]); + + filter.kind = FILTER_REFS_BRANCHES; + filter_refs(&candidates, &filter, filter.kind); + + for (i = 0; i < candidates.nr; i++) { + const char *full_name = candidates.items[i]->refname; + const char *short_name; + struct branch *branch; + const char *upstream, *push; + + if (!skip_prefix(full_name, "refs/heads/", &short_name)) + BUG("filter returned non-branch ref '%s'", full_name); + if (branch_checked_out(full_name)) + continue; + + branch = branch_get(short_name); + upstream = branch_get_upstream(branch, NULL); + if (!upstream || !refs_ref_exists(refs, upstream)) + continue; + push = branch_get_push(branch, NULL); + if (!push || !strcmp(push, upstream)) + continue; + + strvec_push(&deletable, short_name); + } + + if (deletable.nr) + ret = delete_branches(deletable.nr, deletable.v, + FILTER_REFS_BRANCHES, + DELETE_BRANCH_SKIP_UNMERGED | + DELETE_BRANCH_NO_HEAD_FALLBACK | + flags); + + strvec_clear(&deletable); + ref_array_clear(&candidates); + ref_filter_clear(&filter); + return ret; +} + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION") static int edit_branch_description(const char *branch_name) @@ -755,6 +810,7 @@ int cmd_branch(int argc, /* possible actions */ int delete = 0, rename = 0, copy = 0, list = 0, unset_upstream = 0, show_current = 0, edit_description = 0; + int delete_merged = 0; const char *new_upstream = NULL; int noncreate_actions = 0; /* possible options */ @@ -808,6 +864,8 @@ int cmd_branch(int argc, OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")), OPT_BOOL(0, "edit-description", &edit_description, N_("edit the description for the branch")), + OPT_BOOL(0, "delete-merged", &delete_merged, + N_("delete local branches whose upstream matches and are merged")), OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE), OPT_MERGED(&filter, N_("print only branches that are merged")), OPT_NO_MERGED(&filter, N_("print only branches that are not merged")), @@ -855,7 +913,8 @@ int cmd_branch(int argc, 0); if (!delete && !rename && !copy && !edit_description && !new_upstream && - !show_current && !unset_upstream && argc == 0) + !show_current && !unset_upstream && !delete_merged && + argc == 0) list = 1; if (filter.with_commit || filter.no_commit || @@ -865,7 +924,7 @@ int cmd_branch(int argc, noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream + !!show_current + !!list + !!edit_description + - !!unset_upstream; + !!unset_upstream + !!delete_merged; if (noncreate_actions > 1) usage_with_options(builtin_branch_usage, options); @@ -907,6 +966,10 @@ int cmd_branch(int argc, (delete > 1 ? DELETE_BRANCH_FORCE : 0) | (quiet ? DELETE_BRANCH_QUIET : 0)); goto out; + } else if (delete_merged) { + ret = delete_merged_branches(argc, argv, + quiet ? DELETE_BRANCH_QUIET : 0); + goto out; } else if (show_current) { print_current_branch_name(); ret = 0; diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index 3104c555f6..609a67bb5a 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1839,4 +1839,109 @@ test_expect_success '--forked narrows a argument' ' test_cmp expect actual ' +test_expect_success '--delete-merged: setup' ' + git init -b main upstream && + ( + cd upstream && + test_commit base && + git checkout -b next && + test_commit next-work && + git checkout main + ) && + git init -b main other && + test_commit -C other other-base && + git init -b main fork +' + +setup_repo_for_delete_merged () { + rm -rf repo && + git clone upstream repo && + ( + cd repo && + git remote add fork ../fork && + git remote add other ../other && + git config remote.pushDefault fork && + git config push.default current && + git fetch other + ) +} + +merged_branch () { + ( + cd repo && + git checkout -b "$1" "$2" && + git commit --allow-empty -m "$1 work" && + git push origin "$1:next" && + git fetch origin && + git branch --set-upstream-to="$2" "$1" + ) +} + +test_expect_success '--delete-merged deletes merged branches and spares the rest' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch merged origin/next && + ( + cd repo && + git checkout -b unmerged origin/next && + git commit --allow-empty -m "unmerged work" && + git branch --set-upstream-to=origin/next unmerged && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout --detach + ) && + sha=$(git -C repo rev-parse --short merged) && + + git -C repo branch --delete-merged origin/next >actual 2>&1 && + + echo "Deleted branch merged (was $sha)." >expect && + test_cmp expect actual && + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + main + tracks-other + unmerged + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged deletes merged branches and spares protected ones' ' + test_when_finished "rm -rf repo" && + setup_repo_for_delete_merged && + merged_branch on-next origin/next && + merged_branch checked-out origin/next && + merged_branch upstream-gone origin/next && + ( + cd repo && + git checkout -b mainline main && + git checkout -b on-local mainline && + git branch --set-upstream-to=mainline on-local && + git update-ref refs/remotes/origin/topic refs/remotes/origin/next && + git branch --set-upstream-to=origin/topic upstream-gone && + git update-ref -d refs/remotes/origin/topic && + git branch --set-upstream-to=origin/main main && + git config branch.main.pushRemote origin && + git checkout -b tracks-other other/main && + git branch --set-upstream-to=other/main tracks-other && + git checkout checked-out + ) && + + git -C repo branch --delete-merged origin/next mainline && + + git -C repo for-each-ref --format="%(refname:short)" refs/heads/ >actual && + cat >expect <<-\EOF && + checked-out + main + mainline + tracks-other + upstream-gone + EOF + test_cmp expect actual +' + +test_expect_success '--delete-merged requires at least one ' ' + test_must_fail git -C forked branch --delete-merged 2>err && + test_grep "requires at least one " err +' + test_done