branch: add --delete-merged <branch>

git branch (--delete-merged <branch>)... [<pattern>...] deletes local branches matching the optional 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. 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 by name to the remote configured by branch.<name>.remote would update its upstream, as determined by mapping the branch ref through that remote's fetch refspec. 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. A branch whose work is not yet merged into its upstream is silently skipped, so one unmerged topic does not abort the whole sweep. A branch that a surviving branch depends on through a chain of local upstreams is also kept, so no branch is deleted out from under stacked work. Collect this transitive set without changing the candidate set during ref iteration: walk upstream chains from surviving branches, visit each branch at most once, and remove the collected bases only after the iteration completes. This makes the result independent of ref iteration order without repeated full scans. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Jul 25, 2026 at 11:32 UTC 75403cb3a4137eca577bdd7be2361ea62f69f5f3
3 files changed +390 -2
Documentation/git-branch.adoc
+30
@@ -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 <branch>)... [<pattern>...]
29
30 DESCRIPTION
31 -----------
@@ -201,6 +202,35 @@ 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 <branch>`::
206 + Delete local branches whose configured upstream matches
207 + _<branch>_, 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. The option can be repeated to widen the upstream match.
211 + Optional _<pattern>_ arguments limit which local branches are
212 + considered, e.g. `git branch --delete-merged 'origin/*'
213 + 'topic-*'`.
214 ++
215 +A branch is not deleted when:
216 ++
217 +--
218 +* its configured upstream ref no longer exists,
219 +* it is checked out in any worktree, or
220 +* pushing it by name to the remote configured by
221 + `branch.<name>.remote` would update its upstream, so it cannot be
222 + distinguished from a branch that just looks "fully merged" right
223 + after a pull.
224 +--
225 ++
226 +A branch whose work has not yet been merged into its upstream is
227 +silently skipped. Delete it with `git branch -D` if you want to
228 +remove it anyway.
229 ++
230 +A branch that a surviving branch depends on through a chain of local
231 +upstreams is kept, so a branch is never deleted out from under stacked
232 +work.
233 +
234 `-v`::
235 `-vv`::
236 `--verbose`::
builtin/branch.c
+156 -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,7 @@ 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 <branch>)... [<pattern>...]"),
43 NULL
44 };
45
@@ -699,6 +701,148 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
701 return 0;
702 }
703
704 +struct stacked_branch_data {
705 + struct strset *deletable_branch_names;
706 + struct strset *protected_branch_names;
707 + struct strset *visited_branch_names;
708 +};
709 +
710 +static int collect_stacked_branch_bases(const struct reference *ref,
711 + void *cb_data)
712 +{
713 + struct stacked_branch_data *data = cb_data;
714 + const char *branch_name;
715 +
716 + if (!skip_prefix(ref->name, "refs/heads/", &branch_name))
717 + BUG("expected local branch ref, got '%s'", ref->name);
718 + if (strset_contains(data->deletable_branch_names, branch_name))
719 + return 0;
720 +
721 + while (strset_add(data->visited_branch_names, branch_name)) {
722 + struct branch *branch = branch_get(branch_name);
723 + const char *upstream_refname = branch_get_upstream(branch, NULL);
724 + const char *upstream_branch_name;
725 +
726 + if (!upstream_refname ||
727 + !skip_prefix(upstream_refname, "refs/heads/",
728 + &upstream_branch_name) ||
729 + !strset_contains(data->deletable_branch_names,
730 + upstream_branch_name))
731 + break;
732 +
733 + strset_add(data->protected_branch_names, upstream_branch_name);
734 + branch_name = upstream_branch_name;
735 + }
736 +
737 + return 0;
738 +}
739 +
740 +static void protect_stacked_branch_bases(struct ref_store *refs,
741 + struct strset *deletable_branch_names)
742 +{
743 + struct strset protected_branch_names = STRSET_INIT;
744 + struct strset visited_branch_names = STRSET_INIT;
745 + struct stacked_branch_data data = {
746 + .deletable_branch_names = deletable_branch_names,
747 + .protected_branch_names = &protected_branch_names,
748 + .visited_branch_names = &visited_branch_names,
749 + };
750 + struct refs_for_each_ref_options opts = {
751 + .prefix = "refs/heads/",
752 + };
753 + struct hashmap_iter iter;
754 + struct strmap_entry *entry;
755 +
756 + refs_for_each_ref_ext(refs, collect_stacked_branch_bases, &data, &opts);
757 +
758 + strset_for_each_entry(&protected_branch_names, &iter, entry)
759 + strset_remove(deletable_branch_names, entry->key);
760 +
761 + strset_clear(&visited_branch_names);
762 + strset_clear(&protected_branch_names);
763 +}
764 +
765 +static int branch_pushes_to_upstream(struct branch *branch,
766 + const char *upstream)
767 +{
768 + struct remote *remote = remote_get(remote_for_branch(branch, NULL));
769 + char *tracking = NULL;
770 + int ret = 0;
771 +
772 + if (remote)
773 + tracking = apply_refspecs(&remote->fetch, branch->refname);
774 + if (tracking && !strcmp(tracking, upstream))
775 + ret = 1;
776 +
777 + free(tracking);
778 + return ret;
779 +}
780 +
781 +static int delete_merged_branches(const struct strvec *upstreams,
782 + const char **argv, unsigned int flags)
783 +{
784 + struct ref_store *refs = get_main_ref_store(the_repository);
785 + struct ref_filter filter = REF_FILTER_INIT;
786 + struct ref_array candidates = { 0 };
787 + struct strset deletable_branch_names = STRSET_INIT;
788 + struct strvec branches_to_delete = STRVEC_INIT;
789 + struct hashmap_iter iter;
790 + struct strmap_entry *entry;
791 + int ret = 0;
792 +
793 + for (size_t i = 0; i < upstreams->nr; i++)
794 + if (ref_filter_forked_add(&filter, upstreams->v[i]) < 0)
795 + die(_("'%s' is not a valid branch or pattern"),
796 + upstreams->v[i]);
797 +
798 + filter.kind = FILTER_REFS_BRANCHES;
799 + filter.name_patterns = argv;
800 + filter_refs(&candidates, &filter, filter.kind);
801 +
802 + for (int i = 0; i < candidates.nr; i++) {
803 + const char *branch_refname = candidates.items[i]->refname;
804 + const char *branch_name;
805 + struct branch *branch;
806 + const char *upstream_refname;
807 +
808 + if (!skip_prefix(branch_refname, "refs/heads/", &branch_name))
809 + BUG("filter returned non-branch ref '%s'", branch_refname);
810 + if (branch_checked_out(branch_refname))
811 + continue;
812 +
813 + branch = branch_get(branch_name);
814 + upstream_refname = branch_get_upstream(branch, NULL);
815 + if (!upstream_refname || !refs_ref_exists(refs, upstream_refname))
816 + continue;
817 + if (branch_pushes_to_upstream(branch, upstream_refname))
818 + continue;
819 + if (check_branch_commit(branch_name, branch_name,
820 + &candidates.items[i]->objectname, NULL,
821 + FILTER_REFS_BRANCHES, DELETE_BRANCH_SKIP_UNMERGED))
822 + continue;
823 +
824 + strset_add(&deletable_branch_names, branch_name);
825 + }
826 +
827 + protect_stacked_branch_bases(refs, &deletable_branch_names);
828 +
829 + strset_for_each_entry(&deletable_branch_names, &iter, entry)
830 + strvec_push(&branches_to_delete, entry->key);
831 +
832 + if (branches_to_delete.nr)
833 + ret = delete_branches(branches_to_delete.nr, branches_to_delete.v,
834 + FILTER_REFS_BRANCHES,
835 + DELETE_BRANCH_SKIP_UNMERGED |
836 + DELETE_BRANCH_NO_HEAD_FALLBACK |
837 + flags);
838 +
839 + strvec_clear(&branches_to_delete);
840 + strset_clear(&deletable_branch_names);
841 + ref_array_clear(&candidates);
842 + ref_filter_clear(&filter);
843 + return ret;
844 +}
845 +
846 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
847
848 static int edit_branch_description(const char *branch_name)
@@ -740,6 +884,7 @@ int cmd_branch(int argc,
884 /* possible actions */
885 int delete = 0, rename = 0, copy = 0, list = 0,
886 unset_upstream = 0, show_current = 0, edit_description = 0;
887 + struct strvec delete_merged = STRVEC_INIT;
888 const char *new_upstream = NULL;
889 int noncreate_actions = 0;
890 /* possible options */
@@ -793,6 +938,9 @@ int cmd_branch(int argc,
938 OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
939 OPT_BOOL(0, "edit-description", &edit_description,
940 N_("edit the description for the branch")),
941 + OPT_CALLBACK_F(0, "delete-merged", &delete_merged, N_("branch"),
942 + N_("delete merged branches whose upstream matches <branch> (repeatable)"),
943 + PARSE_OPT_NONEG, parse_opt_strvec),
944 OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
945 OPT_MERGED(&filter, N_("print only branches that are merged")),
946 OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -840,7 +988,8 @@ int cmd_branch(int argc,
988 0);
989
990 if (!delete && !rename && !copy && !edit_description && !new_upstream &&
843 - !show_current && !unset_upstream && argc == 0)
991 + !show_current && !unset_upstream && !delete_merged.nr &&
992 + argc == 0)
993 list = 1;
994
995 if (filter.with_commit || filter.no_commit ||
@@ -850,7 +999,7 @@ int cmd_branch(int argc,
999
1000 noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
1001 !!show_current + !!list + !!edit_description +
853 - !!unset_upstream;
1002 + !!unset_upstream + !!delete_merged.nr;
1003 if (noncreate_actions > 1)
1004 usage_with_options(builtin_branch_usage, options);
1005
@@ -892,6 +1041,10 @@ int cmd_branch(int argc,
1041 (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
1042 (quiet ? DELETE_BRANCH_QUIET : 0));
1043 goto out;
1044 + } else if (delete_merged.nr) {
1045 + ret = delete_merged_branches(&delete_merged, argv,
1046 + quiet ? DELETE_BRANCH_QUIET : 0);
1047 + goto out;
1048 } else if (show_current) {
1049 print_current_branch_name();
1050 ret = 0;
@@ -1051,6 +1204,7 @@ int cmd_branch(int argc,
1204 ret = 0;
1205
1206 out:
1207 + strvec_clear(&delete_merged);
1208 string_list_clear(&sorting_options, 0);
1209 return ret;
1210 }
t/t3200-branch.sh
+204
@@ -1834,4 +1834,208 @@ test_expect_success '--forked requires a value' '
1834 test_grep "requires a value" err
1835 '
1836
1837 +test_expect_success '--delete-merged: setup' '
1838 + git init -b main upstream &&
1839 + (
1840 + cd upstream &&
1841 + test_commit base &&
1842 + git checkout -b next &&
1843 + test_commit next-work &&
1844 + git checkout main
1845 + ) &&
1846 + git init -b main other &&
1847 + test_commit -C other other-base &&
1848 + git init -b main fork
1849 +'
1850 +
1851 +setup_repo_for_delete_merged () {
1852 + rm -rf repo &&
1853 + git clone upstream repo &&
1854 + (
1855 + cd repo &&
1856 + git remote add fork ../fork &&
1857 + git remote add other ../other &&
1858 + git config push.default current &&
1859 + git fetch other
1860 + )
1861 +}
1862 +
1863 +create_merged_branch () {
1864 + (
1865 + cd repo &&
1866 + git checkout -b "$1" origin/next --track &&
1867 + git commit --allow-empty -m "$1 work" &&
1868 + git push origin "$1:next"
1869 + )
1870 +}
1871 +
1872 +check_branches () {
1873 + git for-each-ref --format="%(refname:short)" refs/heads/ >actual &&
1874 + cat >expect &&
1875 + test_cmp expect actual
1876 +}
1877 +
1878 +test_expect_success '--delete-merged keeps cloned main without a default push remote' '
1879 + setup_repo_for_delete_merged &&
1880 + (
1881 + cd repo &&
1882 + git checkout --detach &&
1883 +
1884 + git branch --delete-merged */* &&
1885 +
1886 + check_branches <<-\EOF
1887 + main
1888 + EOF
1889 + )
1890 +'
1891 +
1892 +test_expect_success '--delete-merged deletes only selected merged branches' '
1893 + setup_repo_for_delete_merged &&
1894 + create_merged_branch also-merged &&
1895 + create_merged_branch merged &&
1896 + (
1897 + cd repo &&
1898 + git checkout -b unmerged origin/next --track &&
1899 + git commit --allow-empty -m "unmerged work" &&
1900 + git checkout -b tracks-other other/main --track &&
1901 + sha=$(git rev-parse --short merged) &&
1902 +
1903 + git branch --delete-merged origin/next merged >actual 2>&1 &&
1904 + echo "Deleted branch merged (was $sha)." >expect &&
1905 + test_cmp expect actual &&
1906 +
1907 + check_branches <<-\EOF
1908 + also-merged
1909 + main
1910 + tracks-other
1911 + unmerged
1912 + EOF
1913 + )
1914 +'
1915 +
1916 +test_expect_success '--delete-merged keeps main despite a different default push remote' '
1917 + setup_repo_for_delete_merged &&
1918 + create_merged_branch on-next &&
1919 + create_merged_branch checked-out &&
1920 + create_merged_branch upstream-gone &&
1921 + (
1922 + cd repo &&
1923 + git config remote.pushDefault fork &&
1924 + git checkout -b local-to-delete main --track &&
1925 + git update-ref refs/remotes/origin/topic refs/remotes/origin/next &&
1926 + git branch --set-upstream-to=origin/topic upstream-gone &&
1927 + git update-ref -d refs/remotes/origin/topic &&
1928 + git checkout -b tracks-other other/main --track &&
1929 + git checkout checked-out &&
1930 +
1931 + git branch --delete-merged origin/* \
1932 + --delete-merged main &&
1933 +
1934 + check_branches <<-\EOF
1935 + checked-out
1936 + main
1937 + tracks-other
1938 + upstream-gone
1939 + EOF
1940 + )
1941 +'
1942 +
1943 +test_expect_success '--delete-merged keeps the upstream of a surviving branch' '
1944 + setup_repo_for_delete_merged &&
1945 + create_merged_branch feature &&
1946 + (
1947 + cd repo &&
1948 + git checkout -b topic feature --track &&
1949 + git commit --allow-empty -m "topic work" &&
1950 +
1951 + git branch --delete-merged origin/next 2>err &&
1952 +
1953 + test_must_be_empty err &&
1954 + check_branches <<-\EOF &&
1955 + feature
1956 + main
1957 + topic
1958 + EOF
1959 +
1960 + git config --local --get-regexp "branch\\.(feature|topic)\\.(merge|remote)" >actual &&
1961 + cat >expect <<-\EOF &&
1962 + branch.feature.remote origin
1963 + branch.feature.merge refs/heads/next
1964 + branch.topic.remote .
1965 + branch.topic.merge refs/heads/feature
1966 + EOF
1967 + test_cmp expect actual
1968 + )
1969 +'
1970 +
1971 +test_expect_success '--delete-merged keeps the upstream chain of a surviving branch' '
1972 + setup_repo_for_delete_merged &&
1973 + (
1974 + cd repo &&
1975 + git config remote.pushDefault fork &&
1976 + git branch lower origin/next --track &&
1977 + git branch mid lower --track &&
1978 + git checkout -b tip mid --track &&
1979 + git commit --allow-empty -m "tip work" &&
1980 +
1981 + git branch --delete-merged origin/next \
1982 + --delete-merged lower >actual 2>&1 &&
1983 + test_must_be_empty actual &&
1984 +
1985 + check_branches <<-\EOF &&
1986 + lower
1987 + main
1988 + mid
1989 + tip
1990 + EOF
1991 +
1992 + git config --local --get-regexp "branch\\.(lower|mid|tip)\\.(merge|remote)" >actual &&
1993 + cat >expect <<-\EOF &&
1994 + branch.lower.remote origin
1995 + branch.lower.merge refs/heads/next
1996 + branch.mid.remote .
1997 + branch.mid.merge refs/heads/lower
1998 + branch.tip.remote .
1999 + branch.tip.merge refs/heads/mid
2000 + EOF
2001 + test_cmp expect actual
2002 + )
2003 +'
2004 +
2005 +test_expect_success '--delete-merged result is independent of stacked branch names' '
2006 + setup_repo_for_delete_merged &&
2007 + (
2008 + cd repo &&
2009 + git branch c-lower origin/next --track &&
2010 + git branch b-mid c-lower --track &&
2011 + git checkout -b a-tip b-mid --track &&
2012 + git commit --allow-empty -m "tip work" &&
2013 +
2014 + git branch --delete-merged origin/next \
2015 + --delete-merged "c-*" &&
2016 +
2017 + check_branches <<-\EOF &&
2018 + a-tip
2019 + b-mid
2020 + c-lower
2021 + main
2022 + EOF
2023 +
2024 + git branch --delete-merged origin/next \
2025 + --delete-merged "c-*" >actual 2>&1 &&
2026 + test_must_be_empty actual &&
2027 +
2028 + check_branches <<-\EOF
2029 + a-tip
2030 + b-mid
2031 + c-lower
2032 + main
2033 + EOF
2034 + )
2035 +'
2036 +
2037 +test_expect_success '--delete-merged requires a value' '
2038 + test_must_fail git -C forked branch --delete-merged 2>err &&
2039 + test_grep "requires a value" err
2040 +'
2041 test_done