branch: add --delete-merged <pattern>

git branch (--delete-merged <pattern>)... [<branch-pattern>...] deletes local branches matching the optional branch patterns when their configured upstream matches one of the --delete-merged arguments and their tip is reachable from that upstream. The work has already landed on the upstream they track, so the local copy is no longer needed. Each <pattern> may name a ref, a remote, or a shell glob. The option can be repeated to widen the upstream match. Keeping the candidate patterns as positional arguments lets users bound the set of local branches that may be deleted independently of the upstream selection. A branch is not deleted when: * it is checked out in any worktree * its configured upstream ref no longer exists, since a missing upstream is not by itself a sign of integration * pushing it to the remote configured by branch.<name>.remote would update its upstream, as determined by that remote's configured push and fetch refspecs. For example, a local "main" that tracks "origin/main" is kept even when remote.pushDefault names a fork. Right after a pull it merely looks fully merged. * it is the local upstream of a branch that is not being deleted, so no branch is deleted out from under stacked work. A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. Collect protected local upstreams without changing the candidate set during ref iteration, then remove them after iteration. This makes the result independent of ref iteration order. If a protected branch's own upstream is deleted by the same sweep, clear its upstream configuration. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Jul 30, 2026 at 13:58 UTC 0cfcad455c27ce06f56ba73f9d3f2e4216b75eb7
3 files changed +440 -2
Documentation/git-branch.adoc
+33
@@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
25 git branch (-c|-C) [<old-branch>] <new-branch>
26 git branch (-d|-D) [-r] <branch-name>...
27 git branch --edit-description [<branch-name>]
28 +git branch (--delete-merged <pattern>)... [<branch-pattern>...]
29
30 DESCRIPTION
31 -----------
@@ -201,6 +202,38 @@ This option is only applicable in non-verbose mode.
202 Print the name of the current branch. In detached `HEAD` state,
203 nothing is printed.
204
205 +`--delete-merged <pattern>`::
206 + Delete local branches whose configured upstream matches
207 + _<pattern>_, but only when their tip is reachable from that
208 + upstream. In other words, the work on the branch has already
209 + landed on the upstream it tracks, so the local copy is no longer
210 + needed. _<pattern>_ may name a ref, a remote (using the branch its
211 + `HEAD` points at), or a shell-style glob. The option can be
212 + repeated to widen the upstream match.
213 + Optional _<branch-pattern>_ arguments limit which local branches
214 + are considered, e.g. `git branch --delete-merged 'origin/*'
215 + 'topic-*'`.
216 ++
217 +A branch is not deleted when:
218 ++
219 +--
220 +* its configured upstream ref no longer exists,
221 +* it is checked out in any worktree,
222 +* pushing it to the remote configured by
223 + `branch.<name>.remote` would update its upstream, so it cannot be
224 + distinguished from a branch that just looks fully merged right
225 + after a pull; this is determined by the remote's configured push and
226 + fetch refspecs,
227 +* it is the local upstream of a branch that is not being deleted.
228 +--
229 ++
230 +When such a local upstream branch has its own upstream deleted by the
231 +same operation, its upstream configuration is cleared.
232 ++
233 +A branch whose work has not yet been merged into its upstream is
234 +silently skipped. Delete it with `git branch -D` if you want to
235 +remove it anyway.
236 +
237 `-v`::
238 `-vv`::
239 `--verbose`::
builtin/branch.c
+193 -2
@@ -21,6 +21,7 @@
21 #include "branch.h"
22 #include "path.h"
23 #include "string-list.h"
24 +#include "strmap.h"
25 #include "column.h"
26 #include "utf8.h"
27 #include "ref-filter.h"
@@ -38,6 +39,8 @@ static const char * const builtin_branch_usage[] = {
39 N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
40 N_("git branch [<options>] [-r | -a] [--points-at]"),
41 N_("git branch [<options>] [-r | -a] [--format]"),
42 + N_("git branch [<options>] (--delete-merged <pattern>)... "
43 + "[<branch-pattern>...]"),
44 NULL
45 };
46
@@ -700,6 +703,184 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
703 return 0;
704 }
705
706 +struct stacked_branch_data {
707 + struct strset *deletable_branch_names;
708 + struct strset *protected_branch_names;
709 +};
710 +
711 +static int collect_stacked_branch_base(const struct reference *ref,
712 + void *cb_data)
713 +{
714 + struct stacked_branch_data *data = cb_data;
715 + const char *branch_name;
716 + struct branch *branch;
717 + const char *upstream_refname;
718 + const char *upstream_branch_name;
719 +
720 + if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
721 + BUG("expected local branch ref, got '%s'", ref->name);
722 + if (strset_contains(data->deletable_branch_names, branch_name))
723 + return 0;
724 +
725 + branch = branch_get(branch_name);
726 + upstream_refname = branch_get_upstream(branch, NULL);
727 + if (!upstream_refname ||
728 + !skip_prefix(upstream_refname, "refs/heads/",
729 + &upstream_branch_name) ||
730 + !strset_contains(data->deletable_branch_names,
731 + upstream_branch_name))
732 + return 0;
733 +
734 + strset_add(data->protected_branch_names, upstream_branch_name);
735 + return 0;
736 +}
737 +
738 +static void protect_stacked_branch_bases(struct ref_store *refs,
739 + struct strset *deletable_branch_names,
740 + struct strset *protected_branch_names)
741 +{
742 + struct stacked_branch_data data = {
743 + .deletable_branch_names = deletable_branch_names,
744 + .protected_branch_names = protected_branch_names,
745 + };
746 + struct refs_for_each_ref_options opts = {
747 + .prefix = "refs/heads/",
748 + };
749 + struct hashmap_iter iter;
750 + struct strmap_entry *entry;
751 +
752 + refs_for_each_ref_ext(refs, collect_stacked_branch_base, &data, &opts);
753 +
754 + strset_for_each_entry(protected_branch_names, &iter, entry)
755 + strset_remove(deletable_branch_names, entry->key);
756 +}
757 +
758 +static void clear_deleted_upstreams(struct strset *protected_branch_names,
759 + struct strset *deletable_branch_names)
760 +{
761 + struct strbuf key = STRBUF_INIT;
762 + struct hashmap_iter iter;
763 + struct strmap_entry *entry;
764 +
765 + strset_for_each_entry(protected_branch_names, &iter, entry) {
766 + struct branch *branch = branch_get(entry->key);
767 + const char *upstream_refname = branch_get_upstream(branch, NULL);
768 + const char *upstream_branch_name;
769 +
770 + if (!upstream_refname ||
771 + !skip_prefix(upstream_refname, "refs/heads/",
772 + &upstream_branch_name) ||
773 + !strset_contains(deletable_branch_names,
774 + upstream_branch_name))
775 + continue;
776 +
777 + strbuf_addf(&key, "branch.%s.merge", branch->name);
778 + repo_config_set_gently(the_repository, key.buf, NULL);
779 + strbuf_reset(&key);
780 + strbuf_addf(&key, "branch.%s.remote", branch->name);
781 + repo_config_set_gently(the_repository, key.buf, NULL);
782 + strbuf_reset(&key);
783 + }
784 +
785 + strbuf_release(&key);
786 +}
787 +
788 +static int branch_pushes_to_upstream(struct branch *branch,
789 + const char *upstream)
790 +{
791 + struct remote *remote = remote_get(remote_for_branch(branch, NULL));
792 + char *push_refname = NULL;
793 + char *tracking = NULL;
794 + int ret = 0;
795 +
796 + if (!remote)
797 + return 0;
798 + if (remote->push.nr)
799 + push_refname = apply_refspecs(&remote->push, branch->refname);
800 + else
801 + push_refname = xstrdup(branch->refname);
802 + if (push_refname)
803 + tracking = apply_refspecs(&remote->fetch, push_refname);
804 + if (tracking && !strcmp(tracking, upstream))
805 + ret = 1;
806 +
807 + free(push_refname);
808 + free(tracking);
809 + return ret;
810 +}
811 +
812 +static int delete_merged_branches(const struct strvec *upstreams,
813 + const char **argv, unsigned int flags)
814 +{
815 + struct ref_store *refs = get_main_ref_store(the_repository);
816 + struct ref_filter filter = REF_FILTER_INIT;
817 + struct ref_array candidates = { 0 };
818 + struct strset deletable_branch_names = STRSET_INIT;
819 + struct strset protected_branch_names = STRSET_INIT;
820 + struct strvec branches_to_delete = STRVEC_INIT;
821 + struct hashmap_iter iter;
822 + struct strmap_entry *entry;
823 + int ret = 0;
824 +
825 + for (size_t i = 0; i < upstreams->nr; i++)
826 + if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
827 + die(_("'%s' is not a valid branch or pattern"),
828 + upstreams->v[i]);
829 +
830 + filter.kind = FILTER_REFS_BRANCHES;
831 + filter.name_patterns = argv;
832 + filter_refs(&candidates, &filter, filter.kind);
833 +
834 + for (int i = 0; i < candidates.nr; i++) {
835 + const char *branch_refname = candidates.items[i]->refname;
836 + const char *branch_name;
837 + struct branch *branch;
838 + const char *upstream_refname;
839 +
840 + if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
841 + BUG("filter returned non-branch ref '%s'", branch_refname);
842 + if (branch_checked_out(branch_refname))
843 + continue;
844 +
845 + branch = branch_get(branch_name);
846 + upstream_refname = branch_get_upstream(branch, NULL);
847 + if (!upstream_refname || !refs_ref_exists(refs, upstream_refname))
848 + continue;
849 + if (branch_pushes_to_upstream(branch, upstream_refname))
850 + continue;
851 + if (check_branch_commit(branch_name, branch_name,
852 + &candidates.items[i]->objectname, NULL,
853 + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
854 + continue;
855 +
856 + strset_add(&deletable_branch_names, branch_name);
857 + }
858 +
859 + protect_stacked_branch_bases(refs, &deletable_branch_names,
860 + &protected_branch_names);
861 +
862 + strset_for_each_entry(&deletable_branch_names, &iter, entry)
863 + strvec_push(&branches_to_delete, entry->key);
864 +
865 + if (branches_to_delete.nr)
866 + ret = delete_branches(branches_to_delete.nr, branches_to_delete.v,
867 + FILTER_REFS_BRANCHES,
868 + DELETE_BRANCH_SKIP_UNMERGED |
869 + DELETE_BRANCH_NO_HEAD_FALLBACK |
870 + flags);
871 +
872 + if (!ret)
873 + clear_deleted_upstreams(&protected_branch_names,
874 + &deletable_branch_names);
875 +
876 + strvec_clear(&branches_to_delete);
877 + strset_clear(&protected_branch_names);
878 + strset_clear(&deletable_branch_names);
879 + ref_array_clear(&candidates);
880 + ref_filter_clear(&filter);
881 + return ret;
882 +}
883 +
884 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
885
886 static int edit_branch_description(const char *branch_name)
@@ -764,6 +945,7 @@ int cmd_branch(int argc,
945 /* possible actions */
946 int delete = 0, rename = 0, copy = 0, list = 0,
947 unset_upstream = 0, show_current = 0, edit_description = 0;
948 + struct strvec delete_merged = STRVEC_INIT;
949 const char *new_upstream = NULL;
950 int noncreate_actions = 0;
951 /* possible options */
@@ -817,6 +999,9 @@ int cmd_branch(int argc,
999 OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
1000 OPT_BOOL(0, "edit-description", &edit_description,
1001 N_("edit the description for the branch")),
1002 + OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("pattern"),
1003 + N_("delete merged branches whose upstream matches <pattern> (repeatable)"),
1004 + PARSE_OPT_NONEG, parse_opt_strvec),
1005 OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
1006 OPT_MERGED(&filter, N_("print only branches that are merged")),
1007 OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -864,7 +1049,8 @@ int cmd_branch(int argc,
1049 0);
1050
1051 if (!delete && !rename && !copy && !edit_description && !new_upstream &&
867 - !show_current && !unset_upstream && argc == 0)
1052 + !show_current && !unset_upstream && !delete_merged.nr &&
1053 + argc == 0)
1054 list = 1;
1055
1056 if (filter.with_commit || filter.no_commit ||
@@ -874,7 +1060,7 @@ int cmd_branch(int argc,
1060
1061 noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
1062 !!show_current + !!list + !!edit_description +
877 - !!unset_upstream;
1063 + !!unset_upstream + !!delete_merged.nr;
1064 if (noncreate_actions > 1)
1065 usage_with_options(builtin_branch_usage, options);
1066
@@ -916,6 +1102,10 @@ int cmd_branch(int argc,
1102 (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
1103 (quiet ? DELETE_BRANCH_QUIET : 0));
1104 goto out;
1105 + } else if (delete_merged.nr) {
1106 + ret = delete_merged_branches(&delete_merged, argv,
1107 + quiet ? DELETE_BRANCH_QUIET : 0);
1108 + goto out;
1109 } else if (show_current) {
1110 print_current_branch_name();
1111 ret = 0;
@@ -1087,6 +1277,7 @@ int cmd_branch(int argc,
1277 ret = 0;
1278
1279 out:
1280 + strvec_clear(&delete_merged);
1281 string_list_clear(&sorting_options, 0);
1282 return ret;
1283 }
t/t3200-branch.sh
+214
@@ -1870,4 +1870,218 @@ test_expect_success '--forked requires a value' '
1870 test_grep "requires a value" err
1871 '
1872
1873 +test_expect_success '--delete-merged: setup' '
1874 + git init -b main upstream &&
1875 + (
1876 + cd upstream &&
1877 + test_commit base &&
1878 + git checkout -b next &&
1879 + test_commit next-work &&
1880 + git checkout main
1881 + ) &&
1882 + git init -b main other &&
1883 + test_commit -C other other-base &&
1884 + git init -b main fork
1885 +'
1886 +
1887 +setup_repo_for_delete_merged () {
1888 + rm -rf repo &&
1889 + git clone upstream repo &&
1890 + (
1891 + cd repo &&
1892 + git remote add fork ../fork &&
1893 + git remote add other ../other &&
1894 + git config push.default current &&
1895 + git fetch other
1896 + )
1897 +}
1898 +
1899 +create_merged_branch () {
1900 + (
1901 + cd repo &&
1902 + git checkout -b "$1" --track origin/next &&
1903 + git commit --allow-empty -m "$1 work" &&
1904 + git push origin "$1:next"
1905 + )
1906 +}
1907 +
1908 +check_branches () {
1909 + git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
1910 + cat >expect &&
1911 + test_cmp expect actual
1912 +}
1913 +
1914 +test_expect_success '--delete-merged keeps cloned main without explicit push configuration' '
1915 + setup_repo_for_delete_merged &&
1916 + (
1917 + cd repo &&
1918 + test_cmp_config origin branch.main.remote &&
1919 + test_cmp_config refs/heads/main branch.main.merge &&
1920 + git checkout --detach &&
1921 +
1922 + git branch --delete-merged */* &&
1923 +
1924 + check_branches <<-\EOF
1925 + main
1926 + EOF
1927 + )
1928 +'
1929 +
1930 +test_expect_success '--delete-merged deletes only selected merged branches' '
1931 + setup_repo_for_delete_merged &&
1932 + create_merged_branch also-merged &&
1933 + create_merged_branch merged &&
1934 + (
1935 + cd repo &&
1936 + git checkout -b unmerged --track origin/next &&
1937 + git commit --allow-empty -m "unmerged work" &&
1938 + git checkout -b tracks-other --track other/main &&
1939 + sha=$(git rev-parse --short merged) &&
1940 +
1941 + git branch --delete-merged origin/next merged >actual 2>&1 &&
1942 + echo "Deleted branch merged (was $sha)." >expect &&
1943 + test_cmp expect actual &&
1944 +
1945 + check_branches <<-\EOF
1946 + also-merged
1947 + main
1948 + tracks-other
1949 + unmerged
1950 + EOF
1951 + )
1952 +'
1953 +
1954 +test_expect_success '--delete-merged keeps main despite a different default push remote' '
1955 + setup_repo_for_delete_merged &&
1956 + create_merged_branch on-next &&
1957 + create_merged_branch checked-out &&
1958 + create_merged_branch upstream-gone &&
1959 + (
1960 + cd repo &&
1961 + git config remote.pushDefault fork &&
1962 + git checkout -b local-to-delete --track main &&
1963 + git config branch.upstream-gone.merge refs/heads/topic &&
1964 + git checkout -b tracks-other --track other/main &&
1965 + git checkout checked-out &&
1966 +
1967 + git branch --delete-merged origin/* --delete-merged main &&
1968 +
1969 + check_branches <<-\EOF
1970 + checked-out
1971 + main
1972 + tracks-other
1973 + upstream-gone
1974 + EOF
1975 + )
1976 +'
1977 +
1978 +test_expect_success '--delete-merged maps push refspecs to upstreams' '
1979 + setup_repo_for_delete_merged &&
1980 + (
1981 + cd repo &&
1982 + git checkout -b topic &&
1983 + git config remote.origin.push "refs/heads/topic:refs/heads/published" &&
1984 + git push origin &&
1985 + git branch --set-upstream-to=origin/published topic &&
1986 + git checkout -b other-topic --track origin/published &&
1987 + git checkout --detach &&
1988 +
1989 + git branch --delete-merged origin/published &&
1990 +
1991 + check_branches <<-\EOF
1992 + main
1993 + topic
1994 + EOF
1995 + )
1996 +'
1997 +
1998 +test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
1999 + setup_repo_for_delete_merged &&
2000 + create_merged_branch feature &&
2001 + (
2002 + cd repo &&
2003 + git checkout -b topic --track feature &&
2004 + git commit --allow-empty -m "topic work" &&
2005 +
2006 + git branch --delete-merged origin/next 2>err &&
2007 +
2008 + test_must_be_empty err &&
2009 + check_branches <<-\EOF &&
2010 + feature
2011 + main
2012 + topic
2013 + EOF
2014 +
2015 + git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
2016 + cat >expect <<-\EOF &&
2017 + branch.feature.remote origin
2018 + branch.feature.merge refs/heads/next
2019 + branch.topic.remote .
2020 + branch.topic.merge refs/heads/feature
2021 + EOF
2022 + test_cmp expect actual
2023 + )
2024 +'
2025 +
2026 +test_expect_success '--delete-merged clears the deleted upstream of a protected branch' '
2027 + setup_repo_for_delete_merged &&
2028 + (
2029 + cd repo &&
2030 + git branch --track lower origin/next &&
2031 + git branch --track mid lower &&
2032 + git checkout -b tip --track mid &&
2033 + git commit --allow-empty -m "tip work" &&
2034 + sha=$(git rev-parse --short lower) &&
2035 +
2036 + git branch --delete-merged origin/next --delete-merged lower >actual 2>&1 &&
2037 + echo "Deleted branch lower (was $sha)." >expect &&
2038 + test_cmp expect actual &&
2039 +
2040 + check_branches <<-\EOF &&
2041 + main
2042 + mid
2043 + tip
2044 + EOF
2045 +
2046 + git config --local --get-regexp "branch\\.(mid|tip)\\.(merge|remote)" >actual &&
2047 + cat >expect <<-\EOF &&
2048 + branch.tip.remote .
2049 + branch.tip.merge refs/heads/mid
2050 + EOF
2051 + test_cmp expect actual
2052 + )
2053 +'
2054 +
2055 +test_expect_success '--delete-merged result is independent of stacked branch names' '
2056 + setup_repo_for_delete_merged &&
2057 + (
2058 + cd repo &&
2059 + git branch --track c-lower origin/next &&
2060 + git branch --track b-mid c-lower &&
2061 + git checkout -b a-tip --track b-mid &&
2062 + git commit --allow-empty -m "tip work" &&
2063 +
2064 + git branch --delete-merged origin/next --delete-merged "c-*" &&
2065 +
2066 + check_branches <<-\EOF &&
2067 + a-tip
2068 + b-mid
2069 + main
2070 + EOF
2071 +
2072 + git branch --delete-merged origin/next --delete-merged "c-*" >actual 2>&1 &&
2073 + test_must_be_empty actual &&
2074 +
2075 + check_branches <<-\EOF
2076 + a-tip
2077 + b-mid
2078 + main
2079 + EOF
2080 + )
2081 +'
2082 +
2083 +test_expect_success '--delete-merged requires a value' '
2084 + test_must_fail git -C forked branch --delete-merged 2>err &&
2085 + test_grep "requires a value" err
2086 +'
2087 test_done