branch: add --forked filter for --list mode

Add a --forked option to "git branch" list mode that lists only branches whose configured upstream matches <branch>. The argument can be a ref (e.g. "origin/main", "master"), a remote name like "origin" for the branch its origin/HEAD points at, or a shell glob (e.g. "origin/*"), and may be repeated to widen the filter. It is an ordinary list filter, so it combines with the others: git branch --merged origin/main --forked 'origin/*' lists branches forked from origin that are already merged into origin/main, and --no-merged inverts the question. This is the building block for --delete-merged, which deletes the listed branches once they have landed on their upstream. 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 9be810b2a3591d89dffdcef39c58d2f59e0d48f0
5 files changed +224 -3
Documentation/git-branch.adoc
+11 -1
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
13 [--column[=<options>] | --no-column] [--sort=<key>]
14 [--merged [<commit>]] [--no-merged [<commit>]]
15 [--contains [<commit>]] [--no-contains [<commit>]]
16 + [(--forked <branch>)...]
17 [--points-at <object>] [--format=<format>]
18 [(-r|--remotes) | (-a|--all)]
19 [--list] [<pattern>...]
@@ -51,7 +52,8 @@ merged into the named commit (i.e. the branches whose tip commits are
52 reachable from the named commit) will be listed. With `--no-merged` only
53 branches not merged into the named commit will be listed. If the _<commit>_
54 argument is missing it defaults to `HEAD` (i.e. the tip of the current
54 -branch).
55 +branch). With `--forked`, only branches whose configured upstream matches
56 +the given branch or pattern will be listed.
57
58 The command's second form creates a new branch head named _<branch-name>_
59 which points to the current `HEAD`, or _<start-point>_ if given. As a
@@ -311,6 +313,14 @@ superproject's "origin/main", but tracks the submodule's "origin/main".
313 Only list branches whose tips are not reachable from
314 _<commit>_ (`HEAD` if not specified). Implies `--list`.
315
316 +`--forked <branch>`::
317 + Only list branches whose configured upstream matches
318 + _<branch>_. The argument can be a ref (e.g. `origin/main`,
319 + `master`), a remote name like `origin` for the branch its
320 + `origin/HEAD` points at, or a shell-style glob (e.g.
321 + `'origin/*'`). The option can be repeated to widen the
322 + filter. Implies `--list`.
323 +
324 `--points-at <object>`::
325 Only list branches of _<object>_.
326
builtin/branch.c
+16 -2
@@ -30,7 +30,7 @@
30 #include "commit-reach.h"
31
32 static const char * const builtin_branch_usage[] = {
33 - N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
33 + N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
34 N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
35 N_("git branch [<options>] [-l] [<pattern>...]"),
36 N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
673 free_worktrees(worktrees);
674 }
675
676 +static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
677 +{
678 + struct ref_filter *filter = opt->value;
679 +
680 + BUG_ON_OPT_NEG(unset);
681 + if (ref_filter_forked_add(filter, arg) < 0)
682 + die(_("'%s' is not a valid branch or pattern"), arg);
683 + return 0;
684 +}
685 +
686 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
687
688 static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ int cmd_branch(int argc,
780 OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
781 OPT_MERGED(&filter, N_("print only branches that are merged")),
782 OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
783 + OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
784 + N_("print only branches whose upstream matches <branch> (repeatable)"),
785 + PARSE_OPT_NONEG, parse_opt_forked),
786 OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
787 OPT_REF_SORT(&sorting_options),
788 OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
@@ -815,7 +828,8 @@ int cmd_branch(int argc,
828 list = 1;
829
830 if (filter.with_commit || filter.no_commit ||
818 - filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
831 + filter.reachable_from || filter.unreachable_from ||
832 + filter.points_at.nr || filter.forked.nr)
833 list = 1;
834
835 noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
ref-filter.c
+70
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
2744 return match_pattern(filter->exclude.v, refname, filter->ignore_case);
2745 }
2746
2747 +static const char *short_upstream_name(const char *full_ref)
2748 +{
2749 + const char *short_name = full_ref;
2750 +
2751 + if (!skip_prefix(short_name, "refs/heads/", &short_name))
2752 + skip_prefix(short_name, "refs/remotes/", &short_name);
2753 + return short_name;
2754 +}
2755 +
2756 +/*
2757 + * Match the configured upstream of a branch against the registered
2758 + * --forked patterns. Exact patterns are compared against the full
2759 + * upstream refname so they are unambiguous; glob patterns are matched
2760 + * against the abbreviated upstream so that a glob such as origin/...
2761 + * works as typed.
2762 + */
2763 +static int filter_forked_match(struct ref_filter *filter, const char *refname)
2764 +{
2765 + const char *short_name;
2766 + struct branch *branch;
2767 + const char *upstream;
2768 +
2769 + if (!skip_prefix(refname, "refs/heads/", &short_name))
2770 + return 0;
2771 + branch = branch_get(short_name);
2772 + if (!branch)
2773 + return 0;
2774 + upstream = branch_get_upstream(branch, NULL);
2775 + if (!upstream)
2776 + return 0;
2777 +
2778 + for (size_t i = 0; i < filter->forked.nr; i++) {
2779 + const char *pattern = filter->forked.v[i];
2780 + if (has_glob_specials(pattern)) {
2781 + if (!wildmatch(pattern, short_upstream_name(upstream),
2782 + WM_PATHNAME))
2783 + return 1;
2784 + } else if (!strcmp(pattern, upstream)) {
2785 + return 1;
2786 + }
2787 + }
2788 + return 0;
2789 +}
2790 +
2791 +int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
2792 +{
2793 + struct object_id oid;
2794 + char *full_ref = NULL;
2795 +
2796 + if (has_glob_specials(arg)) {
2797 + strvec_push(&filter->forked, arg);
2798 + return 0;
2799 + }
2800 +
2801 + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
2802 + &full_ref, 0) == 1 &&
2803 + (starts_with(full_ref, "refs/heads/") ||
2804 + starts_with(full_ref, "refs/remotes/"))) {
2805 + strvec_push(&filter->forked, full_ref);
2806 + free(full_ref);
2807 + return 0;
2808 + }
2809 + free(full_ref);
2810 + return -1;
2811 +}
2812 +
2813 /*
2814 * We need to seek to the reference right after a given marker but excluding any
2815 * matching references. So we seek to the lexicographically next reference.
@@ -2979,6 +3045,9 @@ static struct ref_array_item *apply_ref_filter(const struct reference *ref,
3045 if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
3046 return NULL;
3047
3048 + if (filter->forked.nr && !filter_forked_match(filter, ref->name))
3049 + return NULL;
3050 +
3051 /*
3052 * A merge filter is applied on refs pointing to commits. Hence
3053 * obtain the commit using the 'oid' available and discard all
@@ -3765,6 +3834,7 @@ void ref_filter_init(struct ref_filter *filter)
3834 void ref_filter_clear(struct ref_filter *filter)
3835 {
3836 strvec_clear(&filter->exclude);
3837 + strvec_clear(&filter->forked);
3838 oid_array_clear(&filter->points_at);
3839 commit_list_free(filter->with_commit);
3840 commit_list_free(filter->no_commit);
ref-filter.h
+10
@@ -67,6 +67,7 @@ struct ref_filter {
67 const char **name_patterns;
68 const char *start_after;
69 struct strvec exclude;
70 + struct strvec forked;
71 struct oid_array points_at;
72 struct commit_list *with_commit;
73 struct commit_list *no_commit;
@@ -110,6 +111,7 @@ struct ref_format {
111 #define REF_FILTER_INIT { \
112 .points_at = OID_ARRAY_INIT, \
113 .exclude = STRVEC_INIT, \
114 + .forked = STRVEC_INIT, \
115 }
116 #define REF_FORMAT_INIT { \
117 .use_color = GIT_COLOR_UNKNOWN, \
@@ -172,6 +174,14 @@ void ref_sorting_release(struct ref_sorting *);
174 struct ref_sorting *ref_sorting_options(struct string_list *);
175 /* Function to parse --merged and --no-merged options */
176 int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
177 +/*
178 + * Register a --forked <branch> pattern on the filter. The argument is
179 + * either a ref, which is resolved to its full refname, or a shell-style
180 + * glob. Branches are kept only when their configured upstream matches
181 + * one of the registered patterns. Returns -1 if the argument is not a
182 + * valid ref or pattern.
183 + */
184 +int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
185 /* Get the current HEAD's description */
186 char *get_head_description(void);
187 /* Set up translated strings in the output. */
t/t3200-branch.sh
+117
@@ -1717,4 +1717,121 @@ test_expect_success 'errors if given a bad branch name' '
1717 test_cmp expect actual
1718 '
1719
1720 +test_expect_success '--forked: setup' '
1721 + test_create_repo forked-upstream &&
1722 + (
1723 + cd forked-upstream &&
1724 + test_commit base &&
1725 + git branch one base &&
1726 + git branch two base
1727 + ) &&
1728 +
1729 + test_create_repo forked-other &&
1730 + (
1731 + cd forked-other &&
1732 + test_commit other-base &&
1733 + git branch foreign other-base
1734 + ) &&
1735 +
1736 + git clone forked-upstream forked &&
1737 + (
1738 + cd forked &&
1739 + git remote add -f other ../forked-other &&
1740 + git branch local-base &&
1741 + git branch --track local-one origin/one &&
1742 + git branch --track local-two origin/two &&
1743 + git branch --track local-foreign other/foreign &&
1744 + git branch --track local-onbase local-base &&
1745 +
1746 + git checkout local-one &&
1747 + test_commit --no-tag local-one-work local-one.t &&
1748 + git checkout local-foreign &&
1749 + test_commit --no-tag local-foreign-work local-foreign.t
1750 + )
1751 +'
1752 +
1753 +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
1754 + git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
1755 + echo local-one >expect &&
1756 + test_cmp expect actual
1757 +'
1758 +
1759 +test_expect_success '--forked <glob> filters by wildmatch' '
1760 + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
1761 + cat >expect <<-\EOF &&
1762 + local-one
1763 + local-two
1764 + main
1765 + EOF
1766 + test_cmp expect actual
1767 +'
1768 +
1769 +test_expect_success '--forked <local-branch> matches branches with local upstream' '
1770 + git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
1771 + echo local-onbase >expect &&
1772 + test_cmp expect actual
1773 +'
1774 +
1775 +test_expect_success '--forked can be repeated to widen the filter' '
1776 + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
1777 + cat >expect <<-\EOF &&
1778 + local-foreign
1779 + local-one
1780 + EOF
1781 + test_cmp expect actual
1782 +'
1783 +
1784 +test_expect_success '--forked combines literal and glob arguments' '
1785 + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
1786 + cat >expect <<-\EOF &&
1787 + local-foreign
1788 + local-onbase
1789 + EOF
1790 + test_cmp expect actual
1791 +'
1792 +
1793 +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
1794 + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
1795 + cat >expect <<-\EOF &&
1796 + local-foreign
1797 + local-one
1798 + local-two
1799 + main
1800 + EOF
1801 + test_cmp expect actual
1802 +'
1803 +
1804 +test_expect_success '--forked composes with --no-merged' '
1805 + git -C forked branch --forked "origin/*" --no-merged origin/one \
1806 + --format="%(refname:short)" >actual &&
1807 + echo local-one >expect &&
1808 + test_cmp expect actual
1809 +'
1810 +
1811 +test_expect_success '--forked <remote> uses the branch <remote>/HEAD points at' '
1812 + git -C forked branch --forked origin --format="%(refname:short)" >actual &&
1813 + echo main >expect &&
1814 + test_cmp expect actual
1815 +'
1816 +
1817 +test_expect_success '--forked narrows a <pattern> argument' '
1818 + git -C forked branch --forked "origin/*" "local-*" \
1819 + --format="%(refname:short)" >actual &&
1820 + cat >expect <<-\EOF &&
1821 + local-one
1822 + local-two
1823 + EOF
1824 + test_cmp expect actual
1825 +'
1826 +
1827 +test_expect_success '--forked rejects unknown branch/pattern' '
1828 + test_must_fail git -C forked branch --forked nope 2>err &&
1829 + test_grep "not a valid branch or pattern" err
1830 +'
1831 +
1832 +test_expect_success '--forked requires a value' '
1833 + test_must_fail git -C forked branch --forked 2>err &&
1834 + test_grep "requires a value" err
1835 +'
1836 +
1837 test_done