branch: prune-merged#2285
Conversation
7bb8db6 to
3fce72f
Compare
4d3e620 to
94014b8
Compare
184a37a to
14e3085
Compare
|
/submit |
|
Submitted as pull.2285.git.git.1777671337839.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com> writes:
> Introduce a tri-state config option that, when --prune (or
> fetch.prune / remote.<name>.prune) removes a remote-tracking
> ref, also deletes local branches whose configured upstream is
> that ref.
>
> Values:
> - false (default): no change in behavior.
> - safe: delete only if the local tip is reachable from the
> upstream tip, preserving any unpushed work.
> - force: delete unconditionally; recoverable only via reflog.
>
> The currently checked-out branch is always preserved.
I do like the feature that allows you to identify which local
branches are already merged and prune them. It will help users keep
their local branch namespace clean.
I however do not like to see the feature tied to "fetch". By this,
I do not mean I do not want an option to trigger the feature when
"git fetch" is run. What I mean is that users should have an option
to prune merged branches without having to fetch first. And you can
then optionally trigger that machinery from "git fetch".
Of course they aleady can do something silly like
$ git branch -d $(git branch --list | sed -e 's/^..//')
and remove all the merged branches, but compared to what is
presented here, one thing missing is that you allow pruning the
local branches that are merged only to remote-tracking branches from
a single remote.
To break the feature down to make it easier to use by our users with
various needs and workflows, we would benefit from having a
collection of smaller features that can be composed, like these:
* "git branch --forked <remote>" lists local branches that build on
something taken from <remote>s. The option can be given multiple
times to make a union of the results from individual "--forked
<remote>".
- <remote> may be a name of a remote, e.g., "origin" to mean all
the remote-tracking branches "refs/remotes/origin/*",
- <remote> may be "origin/master" to name a specific
remote-tracking branch.
- There may be other handy things to cover with <remote>, like
"--all" that may act as if you listed all the available
<remote> on the command line.
* "git branch --prune-merged <remote>..." is a short-hand for "git
branch -d $(git branch --forked <remote>...".
* "git fetch/pull --prune-merged <remote>" can trigger "git branch
--prune-merged <remote>" after "git fetch" successfully updates
the remote-tracking branches, which should be equivalent to what
you have here..
Some local branches that fork from remote and have their initial
round already merged may not want to be pruned, however. You may
have multi-stage development plans for that topic, and you know
already the second phase would want to build on top of the initial
round, not a random version of the mainline with many topics from
other folks merged in. So you'd rather want to keep the topic
branch around after your initial round has been merged to the
upstream before you start the second phase. This is especially true
if your topic is designed to apply to an existing release (in other
words, a bugfix) and you want to keep the second and subsequent
rounds of the topic to be applicable to the same target version
without contaminating the topic with irrelevant features from others
that happened to have been developed and merged upstream around the
same time.
And we'd need to cater to their needs. By this, I do not mean "they
do not have to use --prune-merged", but by giving them a way to say
"this branch should not be auto-pruned with --prune-merged". |
dd4da62 to
66dac97
Compare
|
/submit |
|
Submitted as pull.2285.v2.git.git.1777919250.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > I do like the feature that allows you to identify which local
> branches are already merged and prune them. It will help users keep
> their local branch namespace clean.
Nice to hear!
> To break the feature down to make it easier to use by our users with
> various needs and workflows, we would benefit from having a
> collection of smaller features that can be composed, like these:
I gave it a shot to implement these, and then I ran it one some local repos
it works really nicely!
Harald |
| @@ -24,6 +24,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> | |||
| git branch (-c|-C) [<old-branch>] <new-branch> | |||
There was a problem hiding this comment.
"Kristoffer Haugsbakk" wrote on the Git mailing list (how to reply to this email):
On Mon, May 4, 2026, at 20:27, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
>
> List local branches whose configured upstream falls within any of
> the given <remote> arguments. <remote> may be either a configured
> remote name (matching all of its remote-tracking refs) or a single
> remote-tracking ref. Multiple <remote> arguments are unioned.
>
> This is the building block for --prune-merged, which deletes the
> listed branches.
>
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
s/remote-tracking refs/remote-tracking branches/g
Here and below and on the other patches.
> ---
> Documentation/git-branch.adoc | 12 ++++
> builtin/branch.c | 110 +++++++++++++++++++++++++++++++++-
> t/t3200-branch.sh | 54 +++++++++++++++++
> 3 files changed, 174 insertions(+), 2 deletions(-)
>[snip]|
User |
66dac97 to
6462642
Compare
|
This patch series was integrated into seen via 827cd04. |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--prune-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Comments? source: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com> |
|
Phillip Wood wrote on the Git mailing list (how to reply to this email): Hi Harald
Just a quick note to say I've not forgotten about this, hopefully I should have time to review it later in the week now I'm back on the list.
Thanks
Phillip
On 22/05/2026 12:31, Harald Nordgren via GitGitGadget wrote:
> After releasing v10, I hard-reset back to v9 and reworked the series from
> there.
> > * The flags now take a branch, not a remote. --forked and --prune-merged
> accept a literal upstream short name like origin/main or a wildmatch
> pattern like origin/*. The old --all-remotes flag is gone, since origin/*
> covers that case.
> * The prune guard now compares @{push} against @{upstream}. A branch is
> spared when these are equal. That is the trunk like case, such as local
> main tracking and pushing to origin/main, where "fully merged to
> upstream" cannot be told apart from "just pulled". Only branches that
> push somewhere other than their upstream, typically fork based topics,
> are candidates. The earlier <remote>/HEAD by name guard that the reviewer
> rejected is gone.
> * New --dry-run for --prune-merged.
> > Harald Nordgren (6):
> branch: add --forked <branch>
> branch: let delete_branches warn instead of error on bulk refusal
> branch: prepare delete_branches for a bulk caller
> branch: add --prune-merged <branch>
> branch: add branch.<name>.pruneMerged opt-out
> branch: add --dry-run for --prune-merged
> > Documentation/config/branch.adoc | 7 +
> Documentation/git-branch.adoc | 42 ++++
> builtin/branch.c | 303 +++++++++++++++++++++++++--
> t/t3200-branch.sh | 347 +++++++++++++++++++++++++++++++
> 4 files changed, 682 insertions(+), 17 deletions(-)
> > > base-commit: aec3f587505a472db67e9462d0702e7d463a449d
> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v11
> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v11
> Pull-Request: https://github.com/git/git/pull/2285
> > Range-diff vs v10:
> > 1: f2df159830 ! 1: b9fddd124a branch: add --forked <branch>
> @@ Metadata
> ## Commit message ##
> branch: add --forked <branch>
> > - git branch --forked <branch>...
> + List local branches whose configured upstream
> + (branch.<name>.merge resolved against branch.<name>.remote)
> + matches any of the given <branch> arguments.
> > - lists local branches whose configured upstream matches any
> - of the given <branch> arguments.
> + Each <branch> is interpreted against the local repository, not
> + against any specific remote:
> > - Each <branch> is resolved to the same kind of ref that
> - branch.<name>.remote and branch.<name>.merge together point at:
> - a remote-tracking branch (e.g. origin/master), or, for branches
> - tracking a local upstream, a local branch (e.g. master).
> - Shell-style globs are also accepted (e.g. 'origin/*'). Multiple
> - arguments are unioned.
> + * a literal upstream short name, e.g. "origin/main" or "master"
> + for a branch whose upstream is local;
> + * a wildmatch pattern, e.g. "origin/*";
> + * a bare configured-remote name, e.g. "origin", which resolves
> + to whatever refs/remotes/origin/HEAD points at, matching how
> + "git checkout -b topic origin" picks a starting point.
> > - This is the building block for --prune-merged.
> + The literal-vs-wildcard distinction is settled at parse time so
> + the per-branch matching loop calls wildmatch() only for genuine
> + wildcards. Multiple <branch> arguments are unioned. Output is
> + sorted by branch name.
> +
> + This is the building block for --prune-merged, which deletes the
> + listed branches once they have landed on their upstream.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mod
> nothing is printed.
> > +`--forked`::
> -+ List local branches whose configured upstream matches any
> -+ of the given _<branch>_ arguments. Each argument is either
> -+ a ref (e.g. `origin/master`, `master`) or a shell-style
> -+ glob (e.g. `'origin/*'`). Multiple arguments are unioned.
> ++ List local branches whose configured upstream
> ++ (`branch.<name>.merge` resolved against `branch.<name>.remote`)
> ++ matches any of the given _<branch>_ arguments.
> +++
> ++Each _<branch>_ is interpreted against the local repository: a literal
> ++upstream like `origin/main` or a local branch like `master`, or a
> ++wildmatch pattern like `'origin/*'`. A bare configured-remote name
> ++(e.g. `origin`) resolves to the target of `refs/remotes/<remote>/HEAD`,
> ++to match the way `git checkout -b topic origin` picks a starting
> ++point. Multiple _<branch>_ arguments are unioned.
> +
> `-v`::
> `-vv`::
> @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> NULL
> };
> > -@@ 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,
> -- int kinds, int force)
> -+ int kinds, int force, int warn_only,
> -+ int *n_not_merged)
> - {
> - struct commit *rev = lookup_commit_reference(the_repository, oid);
> - if (!force && !rev) {
> -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
> - return -1;
> - }
> - if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
> -- error(_("the branch '%s' is not fully merged"), branchname);
> -- advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> -- _("If you are sure you want to delete it, "
> -- "run 'git branch -D %s'"), branchname);
> -+ if (warn_only) {
> -+ warning(_("the branch '%s' is not fully merged"),
> -+ branchname);
> -+ } else {
> -+ error(_("the branch '%s' is not fully merged"),
> -+ branchname);
> -+ advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> -+ _("If you are sure you want to delete it, "
> -+ "run 'git branch -D %s'"), branchname);
> -+ }
> -+ if (n_not_merged)
> -+ (*n_not_merged)++;
> - return -1;
> - }
> - return 0;
> -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
> - }
> -
> - static int delete_branches(int argc, const char **argv, int force, int kinds,
> -- int quiet)
> -+ int quiet, int warn_only, int *n_not_merged)
> - {
> - struct commit *head_rev = NULL;
> - struct object_id oid;
> -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
> -
> - if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
> - check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
> -- force)) {
> -- ret = 1;
> -+ force, warn_only, n_not_merged)) {
> -+ if (!warn_only)
> -+ ret = 1;
> - goto next;
> - }
> -
> @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
> free_worktrees(worktrees);
> }
> > ++struct upstream_pattern {
> ++ char *name;
> ++ int is_wildcard;
> ++};
> ++
> ++static void upstream_pattern_list_clear(struct upstream_pattern *items,
> ++ size_t nr)
> ++{
> ++ size_t i;
> ++ for (i = 0; i < nr; i++)
> ++ free(items[i].name);
> ++ free(items);
> ++}
> ++
> ++static const char *short_upstream_name(const char *full_ref)
> ++{
> ++ const char *short_name = full_ref;
> ++ (void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
> ++ skip_prefix(short_name, "refs/remotes/", &short_name));
> ++ return short_name;
> ++}
> ++
> ++static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
> ++{
> ++ struct ref_store *refs = get_main_ref_store(the_repository);
> ++ struct remote *remote;
> ++ struct object_id oid;
> ++ char *full_ref = NULL;
> ++ struct strbuf head_ref = STRBUF_INIT;
> ++ const char *resolved;
> ++
> ++ if (has_glob_specials(arg)) {
> ++ out->name = xstrdup(arg);
> ++ out->is_wildcard = 1;
> ++ return 0;
> ++ }
> ++
> ++ remote = remote_get(arg);
> ++ if (remote && remote_is_configured(remote, 0)) {
> ++ strbuf_addf(&head_ref, "refs/remotes/%s/HEAD", remote->name);
> ++ resolved = refs_resolve_ref_unsafe(refs, head_ref.buf,
> ++ RESOLVE_REF_NO_RECURSE,
> ++ NULL, NULL);
> ++ if (resolved && starts_with(resolved, "refs/remotes/")) {
> ++ out->name = xstrdup(short_upstream_name(resolved));
> ++ out->is_wildcard = 0;
> ++ strbuf_release(&head_ref);
> ++ return 0;
> ++ }
> ++ strbuf_release(&head_ref);
> ++ }
> ++
> ++ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> ++ &full_ref, 0) == 1 &&
> ++ (starts_with(full_ref, "refs/heads/") ||
> ++ starts_with(full_ref, "refs/remotes/"))) {
> ++ out->name = xstrdup(short_upstream_name(full_ref));
> ++ out->is_wildcard = 0;
> ++ free(full_ref);
> ++ return 0;
> ++ }
> ++ free(full_ref);
> ++ return -1;
> ++}
> ++
> +static void parse_forked_args(int argc, const char **argv,
> -+ struct string_list *upstream_patterns)
> ++ struct upstream_pattern **patterns_out,
> ++ size_t *nr_out)
> +{
> ++ struct upstream_pattern *patterns;
> + int i;
> +
> ++ ALLOC_ARRAY(patterns, argc);
> + for (i = 0; i < argc; i++) {
> -+ const char *arg = argv[i];
> -+ struct object_id oid;
> -+ char *full_ref = NULL;
> -+ const char *short_ref;
> -+
> -+ if (has_glob_specials(arg)) {
> -+ string_list_insert(upstream_patterns, arg);
> -+ continue;
> ++ if (parse_one_forked_arg(argv[i], &patterns[i]) < 0) {
> ++ upstream_pattern_list_clear(patterns, i);
> ++ die(_("'%s' is not a valid branch or pattern"),
> ++ argv[i]);
> + }
> ++ }
> ++ *patterns_out = patterns;
> ++ *nr_out = argc;
> ++}
> +
> -+ if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> -+ &full_ref, 0) == 1 &&
> -+ (skip_prefix(full_ref, "refs/heads/", &short_ref) ||
> -+ skip_prefix(full_ref, "refs/remotes/", &short_ref))) {
> -+ string_list_insert(upstream_patterns, short_ref);
> -+ free(full_ref);
> -+ continue;
> -+ }
> -+ free(full_ref);
> ++static int upstream_matches(const char *short_upstream,
> ++ const struct upstream_pattern *patterns,
> ++ size_t nr)
> ++{
> ++ size_t i;
> +
> -+ die(_("'%s' is not a valid branch or pattern"), arg);
> ++ for (i = 0; i < nr; i++) {
> ++ const struct upstream_pattern *p = &patterns[i];
> ++ if (p->is_wildcard) {
> ++ if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
> ++ return 1;
> ++ } else if (!strcmp(p->name, short_upstream)) {
> ++ return 1;
> ++ }
> + }
> ++ return 0;
> +}
> +
> +struct forked_cb {
> -+ const struct string_list *upstream_patterns;
> ++ const struct upstream_pattern *patterns;
> ++ size_t nr_patterns;
> + struct string_list *out;
> +};
> +
> @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
> +{
> + struct forked_cb *cb = cb_data;
> + struct branch *branch;
> -+ const char *upstream, *short_upstream;
> -+ const struct string_list_item *item;
> ++ const char *upstream;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> -+ short_upstream = upstream;
> -+ (void)(skip_prefix(short_upstream, "refs/heads/", &short_upstream) ||
> -+ skip_prefix(short_upstream, "refs/remotes/", &short_upstream));
> -+
> -+ for_each_string_list_item(item, cb->upstream_patterns)
> -+ if (!wildmatch(item->string, short_upstream, WM_PATHNAME)) {
> -+ string_list_append(cb->out, ref->name)->util =
> -+ xstrdup(upstream);
> -+ return 0;
> -+ }
> ++ if (upstream_matches(short_upstream_name(upstream),
> ++ cb->patterns, cb->nr_patterns))
> ++ string_list_append(cb->out, ref->name);
> + return 0;
> +}
> +
> -+static void collect_forked_set(int argc, const char **argv,
> -+ struct string_list *out)
> -+{
> -+ struct string_list upstream_patterns = STRING_LIST_INIT_DUP;
> -+ struct forked_cb cb = {
> -+ .upstream_patterns = &upstream_patterns,
> -+ .out = out,
> -+ };
> -+
> -+ parse_forked_args(argc, argv, &upstream_patterns);
> -+
> -+ refs_for_each_branch_ref(get_main_ref_store(the_repository),
> -+ collect_forked_branch, &cb);
> -+
> -+ string_list_clear(&upstream_patterns, 0);
> -+}
> -+
> +static int list_forked_branches(int argc, const char **argv)
> +{
> ++ struct upstream_pattern *patterns = NULL;
> ++ size_t nr_patterns = 0;
> + struct string_list out = STRING_LIST_INIT_DUP;
> + struct string_list_item *item;
> ++ struct forked_cb cb;
> +
> + if (!argc)
> + die(_("--forked requires at least one <branch>"));
> +
> -+ collect_forked_set(argc, argv, &out);
> ++ parse_forked_args(argc, argv, &patterns, &nr_patterns);
> ++ cb.patterns = patterns;
> ++ cb.nr_patterns = nr_patterns;
> ++ cb.out = &out;
> ++
> ++ refs_for_each_branch_ref(get_main_ref_store(the_repository),
> ++ collect_forked_branch, &cb);
> ++
> ++ string_list_sort(&out);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> -+ string_list_clear(&out, 1);
> ++ upstream_pattern_list_clear(patterns, nr_patterns);
> ++ string_list_clear(&out, 0);
> + return 0;
> +}
> +
> @@ builtin/branch.c: int cmd_branch(int argc,
> usage_with_options(builtin_branch_usage, options);
> > @@ builtin/branch.c: int cmd_branch(int argc,
> - if (delete) {
> - if (!argc)
> die(_("branch name required"));
> -- ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> -+ ret = delete_branches(argc, argv, delete > 1, filter.kind,
> -+ quiet, 0, NULL);
> -+ goto out;
> + ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> + goto out;
> + } else if (forked) {
> + ret = list_forked_branches(argc, argv);
> - goto out;
> ++ goto out;
> } else if (show_current) {
> print_current_branch_name();
> + ret = 0;
> > ## t/t3200-branch.sh ##
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + git clone forked-upstream forked &&
> + git -C forked remote add other ../forked-other &&
> + git -C forked fetch other &&
> ++ git -C forked branch local-base &&
> + git -C forked branch --track local-one origin/one &&
> + git -C forked branch --track local-two origin/two &&
> + git -C forked branch --track local-foreign other/foreign &&
> + git -C forked branch detached &&
> -+ git -C forked branch --track topic-on-main main
> ++ git -C forked branch --track local-trunk local-base
> +'
> +
> -+test_expect_success '--forked <remote-tracking-branch> lists matching branches' '
> ++test_expect_success '--forked <upstream-tracking-branch> lists matching branches' '
> + git -C forked branch --forked origin/one >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> -+test_expect_success '--forked <local-branch> lists branches tracking that local branch' '
> -+ git -C forked branch --forked main >actual &&
> -+ echo topic-on-main >expect &&
> -+ test_cmp expect actual
> -+'
> -+
> -+test_expect_success '--forked <glob> matches every upstream under the pattern' '
> ++test_expect_success '--forked <glob> matches by wildmatch' '
> + git -C forked branch --forked "origin/*" >actual &&
> + cat >expect <<-\EOF &&
> + local-one
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> + test_cmp expect actual
> +'
> +
> ++test_expect_success '--forked <local-branch> matches branches with local upstream' '
> ++ git -C forked branch --forked local-base >actual &&
> ++ echo local-trunk >expect &&
> ++ test_cmp expect actual
> ++'
> ++
> ++test_expect_success '--forked <remote> resolves via refs/remotes/<remote>/HEAD' '
> ++ test_when_finished "git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main" &&
> ++ git -C forked symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/one &&
> ++ git -C forked branch --forked origin >actual &&
> ++ echo local-one >expect &&
> ++ test_cmp expect actual
> ++'
> ++
> +test_expect_success '--forked unions multiple <branch> arguments' '
> + git -C forked branch --forked origin/one other/foreign >actual &&
> + cat >expect <<-\EOF &&
> @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> -+ git -C forked branch --forked main "other/*" >actual &&
> ++ git -C forked branch --forked local-base "other/*" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> -+ topic-on-main
> ++ local-trunk
> + EOF
> + test_cmp expect actual
> +'
> -: ---------- > 2: b666d09bf5 branch: let delete_branches warn instead of error on bulk refusal
> -: ---------- > 3: 6e6580270e branch: prepare delete_branches for a bulk caller
> 2: 718e28c7e0 ! 4: e7e03c1338 branch: add --prune-merged <branch>
> @@ Commit message
> > git branch --prune-merged <branch>...
> > - deletes the local branches that --forked <branch> would list,
> - but only those whose tip is reachable from their configured
> - upstream: the work has already landed on the upstream the
> - branch tracks, so the local copy is no longer needed.
> + deletes the local branches that "--forked <branch>" would list,
> + restricted to 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.
> > - The following branches are always preserved:
> + Reachability is read from the local refs only -- nothing is
> + fetched. Users who want fresh upstream refs run "git fetch" first;
> + the deletion path stays a separate, idempotent step that also
> + works offline.
> > - * the currently checked-out branch in any worktree;
> - * any local branch whose name matches the default branch of
> - any configured remote (the target of
> - refs/remotes/<remote>/HEAD) -- typically 'main' or
> - 'master';
> - * any branch whose upstream no longer resolves locally.
> + Three classes of branches are spared:
> > - Reachability is read from whatever branch.<name>.merge
> - resolves to locally, which is usually a remote-tracking ref
> - but may also be a local branch. When the upstream is a
> - remote-tracking ref, the natural workflow is
> + * any branch checked out in any worktree;
> + * any branch whose upstream no longer resolves locally (its
> + disappearance is not, on its own, evidence of integration);
> + * any branch whose push destination equals its upstream
> + (<branch>@{push} == <branch>@{upstream}). Such a branch
> + cannot be distinguished from a freshly pulled trunk that
> + just looks "fully merged" -- e.g. local "main" tracking and
> + pushing to "origin/main" right after a pull. Only branches
> + that push somewhere other than their upstream (typically
> + topics in a fork-based workflow) are treated as candidates.
> > - git fetch <remote>
> - git branch --prune-merged <upstream-pattern>
> -
> - so the upstream reflects the current state before pruning.
> + Deletion goes through the existing delete_branches() in warn-only
> + mode and with the HEAD-fallback disabled: a branch that is not
> + yet fully merged to its upstream is reported as a one-line warning
> + and skipped, so a single un-mergeable topic does not abort the
> + whole sweep, and there is no fallback to "merged into the
> + currently checked out branch" -- we only act on upstream-merged
> + status.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
> > DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
> - a ref (e.g. `origin/master`, `master`) or a shell-style
> - glob (e.g. `'origin/*'`). Multiple arguments are unioned.
> +@@ Documentation/git-branch.adoc: wildmatch pattern like `'origin/*'`. A bare configured-remote name
> + to match the way `git checkout -b topic origin` picks a starting
> + point. Multiple _<branch>_ arguments are unioned.
> > +`--prune-merged`::
> -+ Delete the local branches that `--forked` would list for
> -+ the same _<branch>_ arguments, but only those whose tip is
> -+ reachable from their configured upstream.
> ++ Delete the local branches that `--forked` would list for the
> ++ same _<branch>_ 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.
> ++
> -+For arguments that refer to remote-tracking branches, run
> -+`git fetch` first so reachability is checked against the
> -+current upstream state; refs are read locally.
> ++Reachability is checked against whatever the upstream refs say
> ++locally; nothing is fetched. Run `git fetch` first if you want
> ++the upstream refs refreshed.
> ++
> -+The following branches are always preserved:
> ++A branch is left alone if any of the following holds:
> ++its upstream no longer resolves locally; it is checked out in any
> ++worktree; or its push destination (`<branch>@{push}`) equals its
> ++upstream (`<branch>@{upstream}`), so it cannot be distinguished
> ++from a freshly pulled trunk that just looks "fully merged".
> ++
> -+--
> -+* the currently checked-out branch in any worktree;
> -+* any local branch whose name matches the default branch of
> -+ any configured remote (the target of
> -+ `refs/remotes/<remote>/HEAD`) -- typically `main` or
> -+ `master`;
> -+* any branch whose upstream no longer resolves locally.
> -+--
> ++Branches refused by the "fully merged" safety check are listed as
> ++warnings and skipped; pass them to `git branch -D` explicitly if
> ++you want them gone.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> > ## builtin/branch.c ##
> -@@
> - #include "branch.h"
> - #include "path.h"
> - #include "string-list.h"
> -+#include "strvec.h"
> - #include "column.h"
> - #include "utf8.h"
> - #include "ref-filter.h"
> -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--points-at]"),
> - N_("git branch [<options>] [-r | -a] [--format]"),
> - N_("git branch [<options>] --forked <branch>..."),
> -+ N_("git branch [<options>] --prune-merged <branch>..."),
> - NULL
> - };
> -
> -@@ 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) {
> -- int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> -+ if (head_rev && head_rev != reference_rev) {
> -+ int expect = repo_in_merge_bases(the_repository, rev, head_rev);
> - if (expect < 0)
> - exit(128);
> - if (expect == merged)
> @@ builtin/branch.c: static int collect_forked_branch(const struct reference *ref, void *cb_data)
> return 0;
> }
> > -+static int collect_default_branch_name(struct remote *remote, void *cb_data)
> -+{
> -+ struct string_list *protected = cb_data;
> -+ struct ref_store *refs = get_main_ref_store(the_repository);
> -+ struct strbuf head = STRBUF_INIT;
> -+ const char *target;
> -+
> -+ strbuf_addf(&head, "refs/remotes/%s/HEAD", remote->name);
> -+ target = refs_resolve_ref_unsafe(refs, head.buf,
> -+ RESOLVE_REF_NO_RECURSE, NULL, NULL);
> -+ if (target) {
> -+ const char *leaf = strrchr(target, '/');
> -+ if (leaf)
> -+ string_list_insert(protected, leaf + 1);
> -+ }
> -+ strbuf_release(&head);
> -+ return 0;
> +-static int list_forked_branches(int argc, const char **argv)
> ++static void collect_forked_set(int argc, const char **argv,
> ++ struct string_list *out)
> + {
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> +- struct string_list out = STRING_LIST_INIT_DUP;
> +- struct string_list_item *item;
> + struct forked_cb cb;
> +
> +- if (!argc)
> +- die(_("--forked requires at least one <branch>"));
> +-
> + parse_forked_args(argc, argv, &patterns, &nr_patterns);
> + cb.patterns = patterns;
> + cb.nr_patterns = nr_patterns;
> +- cb.out = &out;
> ++ cb.out = out;
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> +- string_list_sort(&out);
> ++ string_list_sort(out);
> ++
> ++ upstream_pattern_list_clear(patterns, nr_patterns);
> +}
> +
> - static void collect_forked_set(int argc, const char **argv,
> - struct string_list *out)
> - {
> -@@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
> ++static int list_forked_branches(int argc, const char **argv)
> ++{
> ++ struct string_list out = STRING_LIST_INIT_DUP;
> ++ struct string_list_item *item;
> ++
> ++ if (!argc)
> ++ die(_("--forked requires at least one <branch>"));
> ++
> ++ collect_forked_set(argc, argv, &out);
> + for_each_string_list_item(item, &out)
> + puts(item->string);
> +
> +- upstream_pattern_list_clear(patterns, nr_patterns);
> + string_list_clear(&out, 0);
> return 0;
> }
> > @@ builtin/branch.c: static int list_forked_branches(int argc, const char **argv)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> -+ struct string_list protected_default_names = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> -+ struct strbuf buf = STRBUF_INIT;
> + struct string_list_item *item;
> -+ int n_not_merged = 0;
> + int ret = 0;
> +
> + if (!argc)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(argc, argv, &candidates);
> -+ for_each_remote(collect_default_branch_name, &protected_default_names);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> -+ const char *upstream = item->util;
> -+
> -+ strbuf_reset(&buf);
> -+ strbuf_addf(&buf, "refs/heads/%s", short_name);
> -+ if (branch_checked_out(buf.buf))
> ++ struct branch *branch = branch_get(short_name);
> ++ const char *upstream, *push;
> ++ struct strbuf full = STRBUF_INIT;
> ++ int skip;
> ++
> ++ strbuf_addf(&full, "refs/heads/%s", short_name);
> ++ skip = !!branch_checked_out(full.buf);
> ++ strbuf_release(&full);
> ++ if (skip)
> + continue;
> +
> -+ if (string_list_has_string(&protected_default_names,
> -+ short_name))
> ++ upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> ++ if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> -+
> -+ if (!refs_ref_exists(refs, upstream))
> ++ push = branch ? branch_get_push(branch, NULL) : NULL;
> ++ if (!push || !strcmp(push, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> -+ strbuf_release(&buf);
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> -+ 0, FILTER_REFS_BRANCHES, quiet,
> -+ 1, &n_not_merged);
> -+
> -+ if (n_not_merged && !quiet)
> -+ fprintf(stderr,
> -+ Q_("Skipped %d branch that is not fully merged; "
> -+ "delete it with 'git branch -D' if you are sure.\n",
> -+ "Skipped %d branches that are not fully merged; "
> -+ "delete them with 'git branch -D' if you are sure.\n",
> -+ n_not_merged),
> -+ n_not_merged);
> ++ 0, /* force */
> ++ FILTER_REFS_BRANCHES,
> ++ quiet,
> ++ 1, /* warn_only */
> ++ 1, /* no_head_fallback */
> ++ 0 /* dry_run */);
> +
> + strvec_clear(&deletable);
> -+ string_list_clear(&candidates, 1);
> -+ string_list_clear(&protected_default_names, 0);
> ++ string_list_clear(&candidates, 0);
> + return ret;
> +}
> +
> @@ builtin/branch.c: int cmd_branch(int argc,
> OPT_BOOL(0, "forked", &forked,
> N_("list local branches whose upstream matches the given <branch>...")),
> + OPT_BOOL(0, "prune-merged", &prune_merged,
> -+ N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
> ++ N_("delete local branches whose upstream matches the given <branch>... and is 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")),
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + git -C pm-upstream branch one HEAD~ &&
> + git -C pm-upstream branch two HEAD &&
> + git -C pm-upstream branch wip main &&
> -+ git -C pm-upstream checkout main
> ++ git -C pm-upstream checkout main &&
> ++ test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> + test_when_finished "rm -rf pm-merged" &&
> + git clone pm-upstream pm-merged &&
> ++ git -C pm-merged remote add fork ../pm-fork &&
> ++ test_config -C pm-merged remote.pushDefault fork &&
> ++ test_config -C pm-merged push.default current &&
> + git -C pm-merged branch one one-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next one &&
> + git -C pm-merged branch two two-commit &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged with a literal upstream argument' '
> ++test_expect_success '--prune-merged accepts a literal upstream' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
> ++ git -C pm-literal remote add fork ../pm-fork &&
> ++ test_config -C pm-literal remote.pushDefault fork &&
> ++ test_config -C pm-literal push.default current &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> -+ git -C pm-literal branch keepme one-commit &&
> -+ git -C pm-literal branch --set-upstream-to=origin/main keepme &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> -+ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one &&
> -+ git -C pm-literal rev-parse --verify refs/heads/keepme
> ++ test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> ++ git -C pm-union remote add fork ../pm-fork &&
> ++ test_config -C pm-union remote.pushDefault fork &&
> ++ test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> ++ git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next origin/main &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged with a local-branch argument' '
> -+ test_create_repo pm-local &&
> ++test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> -+ test_commit -C pm-local base &&
> -+ git -C pm-local branch topic base &&
> -+ git -C pm-local config branch.topic.remote . &&
> -+ git -C pm-local config branch.topic.merge refs/heads/main &&
> -+ git -C pm-local checkout --detach &&
> -+
> -+ git -C pm-local branch --prune-merged main &&
> -+
> -+ test_must_fail git -C pm-local rev-parse --verify refs/heads/topic &&
> -+ git -C pm-local rev-parse --verify refs/heads/main
> ++ git clone pm-upstream pm-local &&
> ++ git -C pm-local remote add fork ../pm-fork &&
> ++ test_config -C pm-local remote.pushDefault fork &&
> ++ test_config -C pm-local push.default current &&
> ++ git -C pm-local checkout -b trunk &&
> ++ git -C pm-local branch one one-commit &&
> ++ git -C pm-local branch --set-upstream-to=trunk one &&
> ++ git -C pm-local merge --ff-only one-commit &&
> ++
> ++ git -C pm-local branch --prune-merged trunk &&
> ++
> ++ test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> -+test_expect_success '--prune-merged spares branches with un-integrated commits' '
> ++test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> ++ git -C pm-unmerged remote add fork ../pm-fork &&
> ++ test_config -C pm-unmerged remote.pushDefault fork &&
> ++ test_config -C pm-unmerged push.default current &&
> + git -C pm-unmerged checkout -b wip origin/wip &&
> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-unmerged local-only &&
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> -+ test_grep "Skipped 1 branch" err &&
> -+ test_grep "git branch -D" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> ++test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> ++ test_when_finished "rm -rf pm-nohead" &&
> ++ git clone pm-upstream pm-nohead &&
> ++ git -C pm-nohead remote add fork ../pm-fork &&
> ++ test_config -C pm-nohead remote.pushDefault fork &&
> ++ test_config -C pm-nohead push.default current &&
> ++ git -C pm-nohead branch topic one-commit &&
> ++ git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> ++
> ++ git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> ++
> ++ test_grep ! "not yet merged to HEAD" err &&
> ++ test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> ++'
> ++
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> + test_when_finished "rm -rf pm-upstream-gone" &&
> + git clone pm-upstream pm-upstream-gone &&
> ++ git -C pm-upstream-gone remote add fork ../pm-fork &&
> ++ test_config -C pm-upstream-gone remote.pushDefault fork &&
> ++ test_config -C pm-upstream-gone push.default current &&
> + git -C pm-upstream-gone branch one one-commit &&
> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> + test_when_finished "rm -rf pm-head" &&
> + git clone pm-upstream pm-head &&
> ++ git -C pm-head remote add fork ../pm-fork &&
> ++ test_config -C pm-head remote.pushDefault fork &&
> ++ test_config -C pm-head push.default current &&
> + git -C pm-head checkout -b one one-commit &&
> + git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--forked requires at least one <branch>'
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> -+test_expect_success '--prune-merged spares the local default branch' '
> -+ test_when_finished "rm -rf pm-default" &&
> -+ git clone pm-upstream pm-default &&
> -+ git -C pm-default checkout --detach &&
> -+ git -C pm-default branch --prune-merged "origin/*" &&
> -+ git -C pm-default rev-parse --verify refs/heads/main
> ++test_expect_success '--prune-merged spares branches that push back to their upstream' '
> ++ test_when_finished "rm -rf pm-push-eq" &&
> ++ git clone pm-upstream pm-push-eq &&
> ++ git -C pm-push-eq checkout --detach &&
> ++
> ++ git -C pm-push-eq branch --prune-merged "origin/*" &&
> ++
> ++ git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> -+test_expect_success '--prune-merged protects the default branch by name only' '
> -+ test_when_finished "rm -rf pm-default-alias" &&
> -+ git clone pm-upstream pm-default-alias &&
> -+ git -C pm-default-alias branch --track trunk origin/main &&
> -+ git -C pm-default-alias checkout --detach &&
> -+ git -C pm-default-alias branch --prune-merged "origin/*" &&
> -+ git -C pm-default-alias rev-parse --verify refs/heads/main &&
> -+ test_must_fail git -C pm-default-alias rev-parse --verify refs/heads/trunk
> ++test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> ++ test_when_finished "rm -rf pm-push-branch" &&
> ++ git clone pm-upstream pm-push-branch &&
> ++ git -C pm-push-branch remote add fork ../pm-fork &&
> ++ test_config -C pm-push-branch remote.pushDefault fork &&
> ++ test_config -C pm-push-branch push.default current &&
> ++ test_config -C pm-push-branch branch.main.pushRemote origin &&
> ++ git -C pm-push-branch checkout --detach &&
> ++
> ++ git -C pm-push-branch branch --prune-merged "origin/*" &&
> ++
> ++ git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> -+test_expect_success '--prune-merged with literal arg also protects default-name' '
> -+ test_when_finished "rm -rf pm-literal-default" &&
> -+ git clone pm-upstream pm-literal-default &&
> -+ git -C pm-literal-default checkout --detach &&
> -+ git -C pm-literal-default branch --prune-merged origin/main &&
> -+ git -C pm-literal-default rev-parse --verify refs/heads/main
> ++test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> ++ test_when_finished "rm -rf pm-push-diff" &&
> ++ git clone pm-upstream pm-push-diff &&
> ++ git -C pm-push-diff remote add fork ../pm-fork &&
> ++ test_config -C pm-push-diff remote.pushDefault fork &&
> ++ test_config -C pm-push-diff push.default current &&
> ++ git -C pm-push-diff branch topic one-commit &&
> ++ git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> ++ git -C pm-push-diff checkout --detach &&
> ++
> ++ git -C pm-push-diff branch --prune-merged "origin/*" &&
> ++
> ++ test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires at least one <branch>' '
> -+ test_must_fail git -C pm-upstream branch --prune-merged 2>err &&
> ++ test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "at least one <branch>" err
> +'
> +
> 3: 6e38d7af3a ! 5: 75b6d2366a branch: add branch.<name>.pruneMerged opt-out
> @@ Metadata
> ## Commit message ##
> branch: add branch.<name>.pruneMerged opt-out
> > - Setting branch.<name>.pruneMerged=false exempts that branch
> - from --prune-merged. Useful for topic branches you intend to
> - develop further after an initial round has been merged
> + Setting branch.<name>.pruneMerged=false exempts that branch from
> + "git branch --prune-merged". Useful for a topic branch you want
> + to develop further after an initial round has been merged
> upstream.
> > - Explicit deletion via 'git branch -d' is unaffected.
> + Unless --quiet is given, the skip is reported per branch so the
> + user knows why their topic was preserved.
> +
> + Explicit deletion via "git branch -d" continues to consult the
> + normal merge check and is not affected by this setting.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > @@ Documentation/config/branch.adoc: for details).
> +
> +`branch.<name>.pruneMerged`::
> + If set to `false`, branch _<name>_ is exempt from
> -+ `git branch --prune-merged`. Defaults to true. Explicit
> -+ deletion via `git branch -d` is unaffected.
> ++ `git branch --prune-merged`. Useful for a topic branch you
> ++ intend to develop further after an initial round has been
> ++ merged upstream. Defaults to true. Explicit deletion via
> ++ `git branch -d` is unaffected.
> > ## Documentation/git-branch.adoc ##
> -@@ Documentation/git-branch.adoc: The following branches are always preserved:
> - any configured remote (the target of
> - `refs/remotes/<remote>/HEAD`) -- typically `main` or
> - `master`;
> -+* any branch with `branch.<name>.pruneMerged` set to `false`;
> - * any branch whose upstream no longer resolves locally.
> - --
> -
> +@@ Documentation/git-branch.adoc: the upstream refs refreshed.
> + +
> + A branch is left alone if any of the following holds:
> + its upstream no longer resolves locally; it is checked out in any
> +-worktree; or its push destination (`<branch>@{push}`) equals its
> ++worktree; its push destination (`<branch>@{push}`) equals its
> + upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +-from a freshly pulled trunk that just looks "fully merged".
> ++from a freshly pulled trunk that just looks "fully merged"; or
> ++`branch.<name>.pruneMerged` is set to `false`.
> + +
> + Branches refused by the "fully merged" safety check are listed as
> + warnings and skipped; pass them to `git branch -D` explicitly if
> > ## builtin/branch.c ##
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - for_each_string_list_item(item, &candidates) {
> - const char *short_name = item->string;
> - const char *upstream = item->util;
> -+ int prune_allowed = 1;
> + struct branch *branch = branch_get(short_name);
> + const char *upstream, *push;
> + struct strbuf full = STRBUF_INIT;
> ++ struct strbuf key = STRBUF_INIT;
> + int skip;
> ++ int opt_out;
> > - strbuf_reset(&buf);
> - strbuf_addf(&buf, "refs/heads/%s", short_name);
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + skip = !!branch_checked_out(full.buf);
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - if (!refs_ref_exists(refs, upstream))
> + if (!push || !strcmp(push, upstream))
> continue;
> > -+ strbuf_reset(&buf);
> -+ strbuf_addf(&buf, "branch.%s.prunemerged", short_name);
> -+ if (!repo_config_get_bool(the_repository, buf.buf,
> -+ &prune_allowed) &&
> -+ !prune_allowed) {
> ++ strbuf_addf(&key, "branch.%s.prunemerged", short_name);
> ++ if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
> ++ !opt_out) {
> + if (!quiet)
> -+ fprintf(stderr, _("Skipping '%s' "
> -+ "(branch.%s.pruneMerged is false)\n"),
> ++ fprintf(stderr,
> ++ _("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
> + short_name, short_name);
> ++ strbuf_release(&key);
> + continue;
> + }
> ++ strbuf_release(&key);
> +
> strvec_push(&deletable, short_name);
> }
> - strbuf_release(&buf);
> +
> > ## t/t3200-branch.sh ##
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <branch>' '
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
> +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
> + test_when_finished "rm -rf pm-optout" &&
> + git clone pm-upstream pm-optout &&
> ++ git -C pm-optout remote add fork ../pm-fork &&
> ++ test_config -C pm-optout remote.pushDefault fork &&
> ++ test_config -C pm-optout push.default current &&
> + git -C pm-optout branch one one-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next one &&
> + git -C pm-optout branch two two-commit &&
> + git -C pm-optout branch --set-upstream-to=origin/next two &&
> -+ git -C pm-optout config branch.one.pruneMerged false &&
> ++ test_config -C pm-optout branch.one.pruneMerged false &&
> +
> + git -C pm-optout branch --prune-merged "origin/*" 2>err &&
> +
> @@ t/t3200-branch.sh: test_expect_success '--prune-merged requires at least one <br
> + git clone pm-upstream pm-optout-d &&
> + git -C pm-optout-d branch one one-commit &&
> + git -C pm-optout-d branch --set-upstream-to=origin/next one &&
> -+ git -C pm-optout-d config branch.one.pruneMerged false &&
> ++ test_config -C pm-optout-d branch.one.pruneMerged false &&
> +
> + git -C pm-optout-d branch -d one &&
> + test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> 4: c68d162e22 ! 6: a1a42a6b19 branch: add --dry-run for --prune-merged
> @@ Metadata
> ## Commit message ##
> branch: add --dry-run for --prune-merged
> > - With --dry-run, --prune-merged prints the branches it would
> - delete and exits without touching any ref. Useful for
> - sanity-checking a glob like 'origin/*' before letting it run.
> + With --dry-run, --prune-merged prints the local branches it would
> + delete -- one "Would delete branch <name>" line per candidate --
> + and exits without touching any ref.
> +
> + This is the natural sanity check before letting a broad pattern
> + like 'origin/*' run for real: the @{push}-vs-@{upstream} and
> + unmerged filtering still applies, so the dry-run output is
> + exactly the set that the live run would delete.
> +
> + --dry-run is only meaningful in combination with --prune-merged
> + and is rejected otherwise.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> > @@ Documentation/git-branch.adoc: git branch (-c|-C) [<old-branch>] <new-branch>
> > DESCRIPTION
> -----------
> -@@ Documentation/git-branch.adoc: The following branches are always preserved:
> - * any branch whose upstream no longer resolves locally.
> - --
> +@@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety check are listed as
> + warnings and skipped; pass them to `git branch -D` explicitly if
> + you want them gone.
> > +`--dry-run`::
> -+ With `--prune-merged`, print the branches that would be
> -+ deleted instead of deleting them.
> ++ With `--prune-merged`, print which branches would be
> ++ deleted and exit without touching any ref. Useful for
> ++ sanity-checking a wide pattern like `'origin/*'` before
> ++ committing to the deletion.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> > ## builtin/branch.c ##
> -@@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--points-at]"),
> - N_("git branch [<options>] [-r | -a] [--format]"),
> - N_("git branch [<options>] --forked <branch>..."),
> -- N_("git branch [<options>] --prune-merged <branch>..."),
> -+ N_("git branch [<options>] --prune-merged [--dry-run] <branch>..."),
> - NULL
> - };
> -
> -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
> - }
> -
> - static int delete_branches(int argc, const char **argv, int force, int kinds,
> -- int quiet, int warn_only, int *n_not_merged)
> -+ int quiet, int warn_only, int dry_run,
> -+ int *n_not_merged)
> - {
> - struct commit *head_rev = NULL;
> - struct object_id oid;
> -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
> - goto next;
> - }
> -
> -+ if (dry_run) {
> -+ printf(_("Would delete branch '%s'\n"),
> -+ name + branch_name_pos);
> -+ goto next;
> -+ }
> -+
> - item = string_list_append(&refs_to_delete, name);
> - item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
> - : (flags & REF_ISSYMREF) ? target
> @@ 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 quiet)
> -+static int prune_merged_branches(int argc, const char **argv,
> -+ int dry_run, int quiet)
> ++static int prune_merged_branches(int argc, const char **argv, int quiet,
> ++ int dry_run)
> {
> struct ref_store *refs = get_main_ref_store(the_repository);
> struct string_list candidates = STRING_LIST_INIT_DUP;
> @@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv, int quiet)
> - if (deletable.nr)
> - ret = delete_branches(deletable.nr, deletable.v,
> - 0, FILTER_REFS_BRANCHES, quiet,
> -- 1, &n_not_merged);
> -+ 1, dry_run, &n_not_merged);
> + quiet,
> + 1, /* warn_only */
> + 1, /* no_head_fallback */
> +- 0 /* dry_run */);
> ++ dry_run);
> > - if (n_not_merged && !quiet)
> - fprintf(stderr,
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> @@ builtin/branch.c: int cmd_branch(int argc,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> int forked = 0;
> @@ builtin/branch.c: int cmd_branch(int argc,
> @@ builtin/branch.c: int cmd_branch(int argc,
> N_("list local branches whose upstream matches the given <branch>...")),
> OPT_BOOL(0, "prune-merged", &prune_merged,
> - N_("delete local branches whose upstream matches the given <branch>... and that are merged into it")),
> + N_("delete local branches whose upstream matches the given <branch>... and is merged")),
> + OPT_BOOL(0, "dry-run", &dry_run,
> -+ N_("with --prune-merged, only print what would be deleted")),
> ++ N_("with --prune-merged, only print which branches would be deleted")),
> 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")),
> @@ builtin/branch.c: int cmd_branch(int argc,
> - argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
> - 0);
> + if (noncreate_actions > 1)
> + usage_with_options(builtin_branch_usage, options);
> > + if (dry_run && !prune_merged)
> + die(_("--dry-run requires --prune-merged"));
> +
> - if (!delete && !rename && !copy && !edit_description && !new_upstream &&
> - !show_current && !unset_upstream && !forked && !prune_merged &&
> - argc == 0)
> + if (recurse_submodules_explicit) {
> + if (!submodule_propagate_branches)
> + die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
> @@ builtin/branch.c: int cmd_branch(int argc,
> - if (!argc)
> - die(_("branch name required"));
> - ret = delete_branches(argc, argv, delete > 1, filter.kind,
> -- quiet, 0, NULL);
> -+ quiet, 0, 0, NULL);
> - goto out;
> - } else if (forked) {
> ret = list_forked_branches(argc, argv);
> goto out;
> } else if (prune_merged) {
> - ret = prune_merged_branches(argc, argv, quiet);
> -+ ret = prune_merged_branches(argc, argv, dry_run, quiet);
> ++ ret = prune_merged_branches(argc, argv, quiet, dry_run);
> goto out;
> } else if (show_current) {
> print_current_branch_name();
> @@ t/t3200-branch.sh: test_expect_success 'branch -d still deletes a pruneMerged=fa
> test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
> '
> > -+test_expect_success '--prune-merged --dry-run prints but does not delete' '
> -+ test_when_finished "rm -rf pm-dryrun" &&
> -+ git clone pm-upstream pm-dryrun &&
> -+ git -C pm-dryrun branch one one-commit &&
> -+ git -C pm-dryrun branch --set-upstream-to=origin/next one &&
> ++test_expect_success '--prune-merged --dry-run lists but does not delete' '
> ++ test_when_finished "rm -rf pm-dry" &&
> ++ git clone pm-upstream pm-dry &&
> ++ git -C pm-dry remote add fork ../pm-fork &&
> ++ test_config -C pm-dry remote.pushDefault fork &&
> ++ test_config -C pm-dry push.default current &&
> ++ git -C pm-dry branch one one-commit &&
> ++ git -C pm-dry branch --set-upstream-to=origin/next one &&
> ++ git -C pm-dry branch two two-commit &&
> ++ git -C pm-dry branch --set-upstream-to=origin/next two &&
> ++
> ++ git -C pm-dry branch --prune-merged --dry-run "origin/*" >actual &&
> ++ test_grep "Would delete branch one " actual &&
> ++ test_grep "Would delete branch two " actual &&
> +
> -+ git -C pm-dryrun branch --prune-merged --dry-run "origin/*" >out &&
> -+ test_grep "Would delete branch .one." out &&
> -+ git -C pm-dryrun rev-parse --verify refs/heads/one
> ++ git -C pm-dry rev-parse --verify refs/heads/one &&
> ++ git -C pm-dry rev-parse --verify refs/heads/two
> +'
> +
> -+test_expect_success '--prune-merged --dry-run skips un-integrated branches' '
> -+ test_when_finished "rm -rf pm-dryrun-unmerged" &&
> -+ git clone pm-upstream pm-dryrun-unmerged &&
> -+ git -C pm-dryrun-unmerged checkout -b wip origin/next &&
> -+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next wip &&
> -+ test_commit -C pm-dryrun-unmerged local-only &&
> -+ git -C pm-dryrun-unmerged checkout - &&
> -+ git -C pm-dryrun-unmerged branch merged one-commit &&
> -+ git -C pm-dryrun-unmerged branch --set-upstream-to=origin/next merged &&
> ++test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
> ++ test_when_finished "rm -rf pm-dry-mixed" &&
> ++ git clone pm-upstream pm-dry-mixed &&
> ++ git -C pm-dry-mixed remote add fork ../pm-fork &&
> ++ test_config -C pm-dry-mixed remote.pushDefault fork &&
> ++ test_config -C pm-dry-mixed push.default current &&
> ++ git -C pm-dry-mixed checkout -b wip origin/next &&
> ++ git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
> ++ test_commit -C pm-dry-mixed local-only &&
> ++ git -C pm-dry-mixed checkout - &&
> ++ git -C pm-dry-mixed branch merged one-commit &&
> ++ git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
> +
> -+ git -C pm-dryrun-unmerged branch --prune-merged --dry-run "origin/*" \
> -+ >out 2>err &&
> -+ test_grep "Would delete branch .merged." out &&
> -+ test_grep ! "Would delete branch .wip." out &&
> -+ test_grep "not fully merged" err &&
> -+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/wip &&
> -+ git -C pm-dryrun-unmerged rev-parse --verify refs/heads/merged
> ++ git -C pm-dry-mixed branch --prune-merged --dry-run "origin/*" >out &&
> ++ test_grep "Would delete branch merged" out &&
> ++ test_grep ! "Would delete branch wip" out &&
> ++ git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
> ++ git -C pm-dry-mixed rev-parse --verify refs/heads/merged
> +'
> +
> -+test_expect_success '--dry-run requires --prune-merged' '
> -+ test_must_fail git -C pm-upstream branch --dry-run 2>err &&
> ++test_expect_success '--dry-run without --prune-merged is rejected' '
> ++ test_must_fail git -C forked branch --dry-run 2>err &&
> + test_grep "requires --prune-merged" err
> +'
> +
> |
|
Harald Nordgren wrote on the Git mailing list (how to reply to this email): > Hi Harald
>
> Just a quick note to say I've not forgotten about this, hopefully I
> should have time to review it later in the week now I'm back on the list.
>
> Thanks
>
> Phillip
Great to hear! Thanks!
Harald |
a1a42a6 to
63a7449
Compare
fe9a51c to
1a0d5ea
Compare
|
/submit |
|
Submitted as pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series was integrated into seen via 0063d59. |
|
There was a status update in the "Cooking" section about the branch "git branch" command learned "--prune-merged" option to remove local branches that have already been merged to the remote-tracking branches they track. Comments? source: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com> |
| @@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current] | |||
| [--merged [<commit>]] [--no-merged [<commit>]] | |||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Add a --forked option to "git branch" list mode that keeps only
> branches whose configured upstream matches <branch>. The argument
> can be a ref (e.g. "origin/main", "master") or a shell-style
> glob (e.g. "origin/*"). The option can be repeated to widen the
> filter.
Do we want to support a remote name as an alias for $remote/HEAD to match "git checkout -b $remote"?
> Because it is a filter on list mode, --forked composes with the
> existing list-mode filters, so
> > git branch --merged origin/main --forked 'origin/*'
> > lists branches forked from origin that have already been
> integrated into origin/main, and --no-merged inverts the question.
Nice
> This is the building block for --prune-merged, which deletes the
> listed branches once they have landed on their upstream.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 7 ++
> builtin/branch.c | 147 +++++++++++++++++++++++++++++++++-
> ref-filter.c | 10 +--
> ref-filter.h | 2 +
> t/t3200-branch.sh | 92 +++++++++++++++++++++
> 5 files changed, 249 insertions(+), 9 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index c0afddc424..8002d7f38c 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -14,6 +14,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
> [--merged [<commit>]] [--no-merged [<commit>]]
> [--contains [<commit>]] [--no-contains [<commit>]]
> [--points-at <object>] [--format=<format>]
> + [(--forked <branch>)...]
Should this come before --format? I think it logically belongs with --merged and --contains which also filter the output.
> [(-r|--remotes) | (-a|--all)]
> [--list] [<pattern>...]
> git branch [--track[=(direct|inherit)] | --no-track] [-f]
> @@ -199,6 +200,12 @@ This option is only applicable in non-verbose mode.
> Print the name of the current branch. In detached `HEAD` state,
> nothing is printed.
> > +`--forked <branch>`::
> + List only branches whose configured upstream matches
> + _<branch>_. The argument can be a ref (e.g. `origin/main`,
> + `master`) or a shell-style glob (e.g. `'origin/*'`). The
> + option can be repeated to widen the filter.
This is fine but do we want to add a sentence to the DESCRIPTION as well where it talks about "--contains" and "--merged"?
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 1572a4f9ef..12711b29cf 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -28,9 +28,10 @@
> #include "help.h"
> #include "advice.h"
> #include "commit-reach.h"
> +#include "wildmatch.h"
> > static const char * const builtin_branch_usage[] = {
> - N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
> + N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
> N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
> N_("git branch [<options>] [-l] [<pattern>...]"),
> N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
> @@ -442,8 +443,12 @@ static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
> return strbuf_detach(&fmt, NULL);
> }
> > +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams);
We try to avoid forward declarations unless they're really needed - can we add the new functions up here instead?
> static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
> - struct ref_format *format, struct string_list *output)
> + struct ref_format *format, struct string_list *output,
> + const struct string_list *forked_upstreams)
> {
> int i;
> struct ref_array array;
> @@ -463,6 +468,9 @@ static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
> > filter_refs(&array, filter, filter->kind);
> > + if (forked_upstreams->nr)
> + filter_array_by_forked(&array, forked_upstreams);
This gets a bit messy below where free elements when we filter "array". It would be much nicer to do the filtering in apply_ref_filter() so that we don't have to allocate those in the first place. I think it would make it simpler to implement --prune-merged as collect_forked_set() would become a call to filter_refs() and we could support --forked in "git for-each-ref".
> +static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
> +{
> + struct object_id oid;
> + char *full_ref = NULL;
> +
> + if (has_glob_specials(arg)) {
> + out->name = xstrdup(arg);
> + out->is_wildcard = 1;
> + return 0;
> + }
> +
> + if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
> + &full_ref, 0) == 1 &&
> + (starts_with(full_ref, "refs/heads/") ||
> + starts_with(full_ref, "refs/remotes/"))) {
> + out->name = xstrdup(short_upstream_name(full_ref));
I don't think abbreviating the refname here is a good idea as short names are inherently ambiguous - in principle you could have a remote tracking branch and a local branch with the same short name. It also means we end up reconstructing the full name in a later patch, instead we should just call short_upstream_name() where we need the abbreviated name.
> +static int upstream_matches(const char *short_upstream,
> + const struct upstream_pattern *patterns,
> + size_t nr)
> +{
> + size_t i;
> +
> + for (i = 0; i < nr; i++) {
> + const struct upstream_pattern *p = &patterns[i];
> + if (p->is_wildcard) {
> + if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
> + return 1;
> + } else if (!strcmp(p->name, short_upstream)) {
> + return 1;
> + }
> + }
This is quadratic but maybe we can assume the user wont pass "--forked" too many times. If this ever becomes a problem we could use an strset for the exact matches and then we only need to loop over the wildmatch patterns but we probably don't need to worry about that now.
> +static int branch_upstream_matches(const char *full_refname,
> + const struct upstream_pattern *patterns,
> + size_t nr_patterns)
> +{
> + const char *short_name;
> + struct branch *branch;
> + const char *upstream;
> +
> + if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> + return 0;
> + branch = branch_get(short_name);
> + if (!branch)
> + return 0;
> + upstream = branch_get_upstream(branch, NULL);
> + if (!upstream)
> + return 0;
> + return upstream_matches(short_upstream_name(upstream),
This would be simpler if we matched on full names.
> +static void filter_array_by_forked(struct ref_array *array,
> + const struct string_list *upstreams)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + int i, kept = 0;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> +
> + for (i = 0; i < array->nr; i++) {
> + struct ref_array_item *item = array->items[i];
> + if (branch_upstream_matches(item->refname,
> + patterns, nr_patterns))
> + array->items[kept++] = item;
> + else
> + free_ref_array_item(item);
> + }
> + array->nr = kept;
As I said above this would be nicer if it was implemented in apply_ref_filter().
> @@ -714,6 +847,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;
> + struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
Personally I'd use a strvec here as we don't need the "util" member of the string list but I'm probably biased as I don't really like the string list api.
I like the idea of making this just another filter to "--list". The basics of the implementation look reasonable - it should be straight forward to match on full refs and move the relavent code into filter-refs.c
Thanks
Phillip
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -767,6 +901,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_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> + N_("list local branches whose upstream matches <branch> (repeatable)")),
> 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")),
> @@ -815,7 +951,8 @@ int cmd_branch(int argc,
> list = 1;
> > if (filter.with_commit || filter.no_commit ||
> - filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
> + filter.reachable_from || filter.unreachable_from ||
> + filter.points_at.nr || forked_upstreams.nr)
> list = 1;
> > noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> @@ -880,7 +1017,8 @@ int cmd_branch(int argc,
> ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
> ref_sorting_set_sort_flags_all(
> sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
> - print_ref_list(&filter, sorting, &format, &output);
> + print_ref_list(&filter, sorting, &format, &output,
> + &forked_upstreams);
> print_columns(&output, colopts, NULL);
> string_list_clear(&output, 0);
> ref_sorting_release(sorting);
> @@ -1020,5 +1158,6 @@ int cmd_branch(int argc,
> > out:
> string_list_clear(&sorting_options, 0);
> + string_list_clear(&forked_upstreams, 0);
> return ret;
> }
> diff --git a/ref-filter.c b/ref-filter.c
> index 1da4c0e60d..65e7bc6785 100644
> --- a/ref-filter.c
> +++ b/ref-filter.c
> @@ -3035,7 +3035,7 @@ static int filter_one(const struct reference *ref, void *cb_data)
> }
> > /* Free memory allocated for a ref_array_item */
> -static void free_array_item(struct ref_array_item *item)
> +void free_ref_array_item(struct ref_array_item *item)
> {
> free((char *)item->symref);
> if (item->value) {
> @@ -3078,7 +3078,7 @@ static int filter_and_format_one(const struct reference *ref, void *cb_data)
> > strbuf_release(&output);
> strbuf_release(&err);
> - free_array_item(item);
> + free_ref_array_item(item);
> > /*
> * Increment the running count of refs that match the filter. If
> @@ -3098,7 +3098,7 @@ void ref_array_clear(struct ref_array *array)
> int i;
> > for (i = 0; i < array->nr; i++)
> - free_array_item(array->items[i]);
> + free_ref_array_item(array->items[i]);
> FREE_AND_NULL(array->items);
> array->nr = array->alloc = 0;
> > @@ -3171,7 +3171,7 @@ static void reach_filter(struct ref_array *array,
> if (is_merged == include_reached)
> array->items[array->nr++] = array->items[i];
> else
> - free_array_item(item);
> + free_ref_array_item(item);
> }
> > clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
> @@ -3667,7 +3667,7 @@ void pretty_print_ref(const char *name, const struct object_id *oid,
> > strbuf_release(&err);
> strbuf_release(&output);
> - free_array_item(ref_item);
> + free_ref_array_item(ref_item);
> }
> > static int parse_sorting_atom(const char *atom)
> diff --git a/ref-filter.h b/ref-filter.h
> index 120221b47f..3883b9dc62 100644
> --- a/ref-filter.h
> +++ b/ref-filter.h
> @@ -155,6 +155,8 @@ void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
> struct ref_format *format);
> /* Clear all memory allocated to ref_array */
> void ref_array_clear(struct ref_array *array);
> +/* Free a single item from a ref_array */
> +void free_ref_array_item(struct ref_array_item *item);
> /* Used to verify if the given format is correct and to parse out the used atoms */
> int verify_ref_format(struct ref_format *format);
> /* Sort the given ref_array as per the ref_sorting provided */
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index e7829c2c4b..4e7deddc04 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
> test_cmp expect actual
> '
> > +test_expect_success '--forked: setup' '
> + test_create_repo forked-upstream &&
> + test_commit -C forked-upstream base &&
> + git -C forked-upstream branch one base &&
> + git -C forked-upstream branch two base &&
> +
> + test_create_repo forked-other &&
> + test_commit -C forked-other other-base &&
> + git -C forked-other branch foreign other-base &&
> +
> + git clone forked-upstream forked &&
> + git -C forked remote add other ../forked-other &&
> + git -C forked fetch other &&
> + git -C forked branch local-base &&
> + git -C forked branch --track local-one origin/one &&
> + git -C forked branch --track local-two origin/two &&
> + git -C forked branch --track local-foreign other/foreign &&
> + git -C forked branch detached &&
> + git -C forked branch --track local-trunk local-base
> +'
> +
> +test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
> + git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <glob> filters by wildmatch' '
> + git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked <local-branch> matches branches with local upstream' '
> + git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
> + echo local-trunk >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked can be repeated to widen the filter' '
> + git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked combines literal and glob arguments' '
> + git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-trunk
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
> + git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
> + cat >expect <<-\EOF &&
> + local-foreign
> + local-one
> + local-two
> + main
> + EOF
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked composes with --no-merged' '
> + test_when_finished "git -C forked checkout detached" &&
> + git -C forked checkout local-one &&
> + test_commit -C forked local-only &&
> + git -C forked branch --forked "origin/*" --no-merged origin/one \
> + --format="%(refname:short)" >actual &&
> + echo local-one >expect &&
> + test_cmp expect actual
> +'
> +
> +test_expect_success '--forked rejects unknown branch/pattern' '
> + test_must_fail git -C forked branch --forked nope 2>err &&
> + test_grep "not a valid branch or pattern" err
> +'
> +
> +test_expect_success '--forked requires a value' '
> + test_must_fail git -C forked branch --forked 2>err &&
> + test_grep "requires a value" err
> +'
> +
> test_doneThere was a problem hiding this comment.
Harald Nordgren wrote on the Git mailing list (how to reply to this email):
Hi Phillip!
Great points all around, I will take a look at implementing them. I'll
respond here instead of for each specific message, and then include
comments as part of the next version.
> > Add a --forked option to "git branch" list mode that keeps only
> > branches whose configured upstream matches <branch>. The argument
> > can be a ref (e.g. "origin/main", "master") or a shell-style
> > glob (e.g. "origin/*"). The option can be repeated to widen the
> > filter.
>
> Do we want to support a remote name as an alias for $remote/HEAD to
> match "git checkout -b $remote"?
I have been going back and forth on this, and while I like the bare
remote, it made the implementation a lot easier after it was removed,
as the arguments from some of the others made sense to me.
Harald| @@ -191,18 +192,24 @@ static int branch_merged(int kind, const char *name, | |||
|
|
|||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Add a warn_only flag to delete_branches() and check_branch_commit()
> so a bulk caller can report not-fully-merged branches as one-line
> warnings and continue, instead of erroring with the four-line "use
> 'git branch -D'" advice that the standalone "git branch -d" path
> emits. Default callers pass 0 and are unaffected.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> builtin/branch.c | 26 +++++++++++++++++---------
> 1 file changed, 17 insertions(+), 9 deletions(-)
> > diff --git a/builtin/branch.c b/builtin/branch.c
> index 12711b29cf..93d8eae891 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -192,7 +192,7 @@ 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,
> - int kinds, int force)
> + int kinds, int force, int warn_only)
We've already got two boolean parameters, lets replace those with an "unsigned int flags" parameter rather than adding a third. That way we can avoid having to comment each argument as you do in a later patch.
Thanks
Phillip
> {
> struct commit *rev = lookup_commit_reference(the_repository, oid);
> if (!force && !rev) {
> @@ -200,10 +200,16 @@ static int check_branch_commit(const char *branchname, const char *refname,
> return -1;
> }
> if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
> - error(_("the branch '%s' is not fully merged"), branchname);
> - advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> - _("If you are sure you want to delete it, "
> - "run 'git branch -D %s'"), branchname);
> + if (warn_only) {
> + warning(_("the branch '%s' is not fully merged"),
> + branchname);
> + } else {
> + error(_("the branch '%s' is not fully merged"),
> + branchname);
> + advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
> + _("If you are sure you want to delete it, "
> + "run 'git branch -D %s'"), branchname);
> + }
> return -1;
> }
> return 0;
> @@ -219,7 +225,7 @@ static void delete_branch_config(const char *branchname)
> }
> > static int delete_branches(int argc, const char **argv, int force, int kinds,
> - int quiet)
> + int quiet, int warn_only)
> {
> struct commit *head_rev = NULL;
> struct object_id oid;
> @@ -309,8 +315,9 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> > if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
> check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
> - force)) {
> - ret = 1;
> + force, warn_only)) {
> + if (!warn_only)
> + ret = 1;
> goto next;
> }
> > @@ -995,7 +1002,8 @@ int cmd_branch(int argc,
> if (delete) {
> if (!argc)
> die(_("branch name required"));
> - ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
> + ret = delete_branches(argc, argv, delete > 1, filter.kind,
> + quiet, 0);
> goto out;
> } else if (show_current) {
> print_current_branch_name();| @@ -168,10 +169,13 @@ static int branch_merged(int kind, const char *name, | |||
| * upstream, if any, otherwise with HEAD", we should just | |||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > Add no_head_fallback and dry_run flags to delete_branches() so a
> bulk caller (the upcoming --prune-merged) can ask strictly about
> merged-into-upstream without a silent fallback to HEAD, and
> rehearse deletions with the same "Would delete branch ..." wording
> as the live run. Existing callers pass 0 for both and keep current
> behavior.
> > When no_head_fallback is set, head_rev stays NULL through to
> branch_merged(), whose "merged to X but not yet merged to HEAD"
> reminder otherwise compares against HEAD. For the bulk caller
> every candidate is known to have an upstream, so HEAD is
> irrelevant. Guard the block on head_rev so the NULL case skips
> it instead of treating "NULL != reference_rev" as "diverges from
> HEAD" and emitting a spurious warning.
Same comment as the last patch - use a flags argument rather than lots of individual booleans that make the call sites hard to read.
Thanks
Phillip
> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> builtin/branch.c | 27 +++++++++++++++++++--------
> 1 file changed, 19 insertions(+), 8 deletions(-)
> > diff --git a/builtin/branch.c b/builtin/branch.c
> index 93d8eae891..09afdd9257 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -169,10 +169,13 @@ static int branch_merged(int kind, const char *name,
> * upstream, if any, otherwise with HEAD", we should just
> * return the result of the repo_in_merge_bases() above without
> * any of the following code, but during the transition period,
> - * a gentle reminder is in order.
> + * a gentle reminder is in order. Callers that opt out of the
> + * HEAD fallback by passing head_rev=NULL are not interested in
> + * the reminder either: they have already established that the
> + * branch has an upstream, so HEAD is irrelevant to the decision.
> */
> - if (head_rev != reference_rev) {
> - int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
> + if (head_rev && head_rev != reference_rev) {
> + int expect = repo_in_merge_bases(the_repository, rev, head_rev);
> if (expect < 0)
> exit(128);
> if (expect == merged)
> @@ -225,7 +228,8 @@ static void delete_branch_config(const char *branchname)
> }
> > static int delete_branches(int argc, const char **argv, int force, int kinds,
> - int quiet, int warn_only)
> + int quiet, int warn_only, int no_head_fallback,
> + int dry_run)
> {
> struct commit *head_rev = NULL;
> struct object_id oid;
> @@ -259,7 +263,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> }
> branch_name_pos = strcspn(fmt, "%");
> > - if (!force)
> + if (!force && !no_head_fallback)
> head_rev = lookup_commit_reference(the_repository, &head_oid);
> > for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
> @@ -330,13 +334,20 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
> free(target);
> }
> > - if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
> + if (!dry_run &&
> + refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
> ret = 1;
> > for_each_string_list_item(item, &refs_to_delete) {
> char *describe_ref = item->util;
> char *name = item->string;
> - if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> + if (dry_run) {
> + if (!quiet)
> + printf(remote_branch
> + ? _("Would delete remote-tracking branch %s (was %s).\n")
> + : _("Would delete branch %s (was %s).\n"),
> + name + branch_name_pos, describe_ref);
> + } else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
> char *refname = name + branch_name_pos;
> if (!quiet)
> printf(remote_branch
> @@ -1003,7 +1014,7 @@ int cmd_branch(int argc,
> if (!argc)
> die(_("branch name required"));
> ret = delete_branches(argc, argv, delete > 1, filter.kind,
> - quiet, 0);
> + quiet, 0, 0, 0);
> goto out;
> } else if (show_current) {
> print_current_branch_name();| @@ -24,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch> | |||
| git branch (-c|-C) [<old-branch>] <new-branch> | |||
There was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
Hi Harald
On 03/06/2026 10:04, Harald Nordgren via GitGitGadget wrote:
> From: Harald Nordgren <haraldnordgren@gmail.com>
> > git branch --prune-merged <branch>...
I'm not sure that synopsis is correct anymore as you need to repeat "--prune-merged". As --prune-merged now takes an argument there is no reason to forbid positional arguments so I think we should support
git branch --prune-merged origin/master 'feature*'
to delete all the branches beginning with "feature" that have the upstream "origin/master" and have been merged.
I wonder about the name - the other options that delete branches are called "delete", not "prune". Also "--prune-merged" does not delete the branches listed by "--merged" so maybe "--delete-forked" would be better?
I've not commented in detail on the code as it will need to change a bit once we match on full refnames and do the filtering in apply_ref_filter() but I think the basics are sound.
I'll stop here - I did quickly scan the next two patches and they both looked like sensible ideas.
Thanks
Phillip
> deletes the local branches that "--forked <branch>" would list,
> restricted to 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.
> > Reachability is read from local refs; nothing is fetched. Users
> who want fresh upstream refs run "git fetch" first.
> > Three classes of branches are spared:
> > * any branch checked out in any worktree;
> * any branch whose upstream no longer resolves locally (its
> disappearance is not, on its own, evidence of integration);
> * any branch whose push destination equals its upstream
> (<branch>@{push} == <branch>@{upstream}). Such a branch
> cannot be distinguished from a freshly pulled trunk that
> just looks "fully merged", e.g. local "main" tracking and
> pushing to "origin/main" right after a pull. Only branches
> that push somewhere other than their upstream (typically
> topics in a fork-based workflow) are treated as candidates.
> > Deletion goes through the existing delete_branches() in warn-only
> mode and with the HEAD-fallback disabled: a branch that is not
> yet fully merged to its upstream is reported as a one-line warning
> and skipped, so a single un-mergeable topic does not abort the
> whole sweep. We only act on upstream-merged status.
> > Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
> ---
> Documentation/git-branch.adoc | 23 +++++
> builtin/branch.c | 117 +++++++++++++++++++--
> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
> 3 files changed, 318 insertions(+), 10 deletions(-)
> > diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
> index 8002d7f38c..f7942fcd7d 100644
> --- a/Documentation/git-branch.adoc
> +++ b/Documentation/git-branch.adoc
> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
> git branch (-c|-C) [<old-branch>] <new-branch>
> git branch (-d|-D) [-r] <branch-name>...
> git branch --edit-description [<branch-name>]
> +git branch (--prune-merged <branch>)...
> > DESCRIPTION
> -----------
> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
> `master`) or a shell-style glob (e.g. `'origin/*'`). The
> option can be repeated to widen the filter.
> > +`--prune-merged <branch>`::
> + Delete the local branches that `--forked` would list for the
> + same _<branch>_, 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. May be given more than once to
> + union the matches; positional arguments are not accepted.
> ++
> +Reachability is checked against whatever the upstream refs say
> +locally; nothing is fetched. Run `git fetch` first if you want
> +the upstream refs refreshed.
> ++
> +A branch is left alone if any of the following holds:
> +its upstream no longer resolves locally; it is checked out in any
> +worktree; or its push destination (`<branch>@{push}`) equals its
> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
> +from a freshly pulled trunk that just looks "fully merged".
> ++
> +Branches refused by the "fully merged" safety check are listed as
> +warnings and skipped; pass them to `git branch -D` explicitly if
> +you want them gone.
> +
> `-v`::
> `-vv`::
> `--verbose`::
> diff --git a/builtin/branch.c b/builtin/branch.c
> index 09afdd9257..736480b002 100644
> --- a/builtin/branch.c
> +++ b/builtin/branch.c
> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
> N_("git branch [<options>] [-r | -a] [--points-at]"),
> N_("git branch [<options>] [-r | -a] [--format]"),
> + N_("git branch [<options>] (--prune-merged <branch>)..."),
> NULL
> };
> > @@ -782,17 +783,13 @@ static int upstream_matches(const char *short_upstream,
> return 0;
> }
> > -static int branch_upstream_matches(const char *full_refname,
> +static int branch_upstream_matches(const char *short_branch_name,
> const struct upstream_pattern *patterns,
> size_t nr_patterns)
> {
> - const char *short_name;
> - struct branch *branch;
> + struct branch *branch = branch_get(short_branch_name);
> const char *upstream;
> > - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
> - return 0;
> - branch = branch_get(short_name);
> if (!branch)
> return 0;
> upstream = branch_get_upstream(branch, NULL);
> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct ref_array *array,
> > for (i = 0; i < array->nr; i++) {
> struct ref_array_item *item = array->items[i];
> - if (branch_upstream_matches(item->refname,
> - patterns, nr_patterns))
> + const char *short_name;
> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
> + branch_upstream_matches(short_name, patterns, nr_patterns))
> array->items[kept++] = item;
> else
> free_ref_array_item(item);
> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct ref_array *array,
> upstream_pattern_list_clear(patterns, nr_patterns);
> }
> > +struct forked_cb {
> + const struct upstream_pattern *patterns;
> + size_t nr_patterns;
> + struct string_list *out;
> +};
> +
> +static int collect_forked_branch(const struct reference *ref, void *cb_data)
> +{
> + struct forked_cb *cb = cb_data;
> +
> + if (ref->flags & REF_ISSYMREF)
> + return 0;
> + if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
> + string_list_append(cb->out, ref->name);
> + return 0;
> +}
> +
> +static void collect_forked_set(const struct string_list *upstreams,
> + struct string_list *out)
> +{
> + struct upstream_pattern *patterns = NULL;
> + size_t nr_patterns = 0;
> + struct forked_cb cb;
> +
> + parse_forked_args(upstreams, &patterns, &nr_patterns);
> + cb.patterns = patterns;
> + cb.nr_patterns = nr_patterns;
> + cb.out = out;
> +
> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
> + collect_forked_branch, &cb);
> +
> + string_list_sort(out);
> +
> + upstream_pattern_list_clear(patterns, nr_patterns);
> +}
> +
> +static int prune_merged_branches(const struct string_list *upstreams,
> + int quiet)
> +{
> + struct ref_store *refs = get_main_ref_store(the_repository);
> + struct string_list candidates = STRING_LIST_INIT_DUP;
> + struct strvec deletable = STRVEC_INIT;
> + struct string_list_item *item;
> + int ret = 0;
> +
> + if (!upstreams->nr)
> + die(_("--prune-merged requires at least one <branch>"));
> +
> + collect_forked_set(upstreams, &candidates);
> +
> + for_each_string_list_item(item, &candidates) {
> + const char *short_name = item->string;
> + struct branch *branch = branch_get(short_name);
> + const char *upstream, *push;
> + struct strbuf full = STRBUF_INIT;
> + int skip;
> +
> + strbuf_addf(&full, "refs/heads/%s", short_name);
> + skip = !!branch_checked_out(full.buf);
> + strbuf_release(&full);
> + if (skip)
> + continue;
> +
> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
> + if (!upstream || !refs_ref_exists(refs, upstream))
> + continue;
> + push = branch ? branch_get_push(branch, NULL) : NULL;
> + if (!push || !strcmp(push, upstream))
> + continue;
> +
> + strvec_push(&deletable, short_name);
> + }
> +
> + if (deletable.nr)
> + ret = delete_branches(deletable.nr, deletable.v,
> + 0, /* force */
> + FILTER_REFS_BRANCHES,
> + quiet,
> + 1, /* warn_only */
> + 1, /* no_head_fallback */
> + 0 /* dry_run */);
> +
> + strvec_clear(&deletable);
> + string_list_clear(&candidates, 0);
> + return ret;
> +}
> +
> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
> > static int edit_branch_description(const char *branch_name)
> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
> int delete = 0, rename = 0, copy = 0, list = 0,
> unset_upstream = 0, show_current = 0, edit_description = 0;
> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
> const char *new_upstream = NULL;
> int noncreate_actions = 0;
> /* possible options */
> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
> N_("edit the description for the branch")),
> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
> N_("list local branches whose upstream matches <branch> (repeatable)")),
> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
> + N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
> 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")),
> @@ -965,7 +1054,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 && !prune_merged_upstreams.nr &&
> + argc == 0)
> list = 1;
> > if (filter.with_commit || filter.no_commit ||
> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
> > noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
> !!show_current + !!list + !!edit_description +
> - !!unset_upstream;
> + !!unset_upstream + !!prune_merged_upstreams.nr;
> if (noncreate_actions > 1)
> usage_with_options(builtin_branch_usage, options);
> > @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
> ret = delete_branches(argc, argv, delete > 1, filter.kind,
> quiet, 0, 0, 0);
> goto out;
> + } else if (prune_merged_upstreams.nr) {
> + if (argc)
> + die(_("--prune-merged does not take positional arguments; "
> + "repeat --prune-merged for each <branch>"));
> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
> + goto out;
> } else if (show_current) {
> print_current_branch_name();
> ret = 0;
> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
> out:
> string_list_clear(&sorting_options, 0);
> string_list_clear(&forked_upstreams, 0);
> + string_list_clear(&prune_merged_upstreams, 0);
> return ret;
> }
> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
> index 4e7deddc04..beb86987ad 100755
> --- a/t/t3200-branch.sh
> +++ b/t/t3200-branch.sh
> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
> test_grep "requires a value" err
> '
> > +test_expect_success '--prune-merged: setup' '
> + test_create_repo pm-upstream &&
> + test_commit -C pm-upstream base &&
> + git -C pm-upstream checkout -b next &&
> + test_commit -C pm-upstream one-commit &&
> + test_commit -C pm-upstream two-commit &&
> + git -C pm-upstream branch one HEAD~ &&
> + git -C pm-upstream branch two HEAD &&
> + git -C pm-upstream branch wip main &&
> + git -C pm-upstream checkout main &&
> + test_create_repo pm-fork
> +'
> +
> +test_expect_success '--prune-merged deletes branches integrated into upstream' '
> + test_when_finished "rm -rf pm-merged" &&
> + git clone pm-upstream pm-merged &&
> + git -C pm-merged remote add fork ../pm-fork &&
> + test_config -C pm-merged remote.pushDefault fork &&
> + test_config -C pm-merged push.default current &&
> + git -C pm-merged branch one one-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next one &&
> + git -C pm-merged branch two two-commit &&
> + git -C pm-merged branch --set-upstream-to=origin/next two &&
> +
> + git -C pm-merged branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a literal upstream' '
> + test_when_finished "rm -rf pm-literal" &&
> + git clone pm-upstream pm-literal &&
> + git -C pm-literal remote add fork ../pm-fork &&
> + test_config -C pm-literal remote.pushDefault fork &&
> + test_config -C pm-literal push.default current &&
> + git -C pm-literal branch one one-commit &&
> + git -C pm-literal branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-literal branch --prune-merged origin/next &&
> +
> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged unions multiple <branch> arguments' '
> + test_when_finished "rm -rf pm-union" &&
> + git clone pm-upstream pm-union &&
> + git -C pm-union remote add fork ../pm-fork &&
> + test_config -C pm-union remote.pushDefault fork &&
> + test_config -C pm-union push.default current &&
> + git -C pm-union branch one one-commit &&
> + git -C pm-union branch --set-upstream-to=origin/next one &&
> + git -C pm-union branch two base &&
> + git -C pm-union branch --set-upstream-to=origin/main two &&
> + git -C pm-union checkout --detach &&
> +
> + git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
> +
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
> +'
> +
> +test_expect_success '--prune-merged accepts a local upstream' '
> + test_when_finished "rm -rf pm-local" &&
> + git clone pm-upstream pm-local &&
> + git -C pm-local remote add fork ../pm-fork &&
> + test_config -C pm-local remote.pushDefault fork &&
> + test_config -C pm-local push.default current &&
> + git -C pm-local checkout -b trunk &&
> + git -C pm-local branch one one-commit &&
> + git -C pm-local branch --set-upstream-to=trunk one &&
> + git -C pm-local merge --ff-only one-commit &&
> +
> + git -C pm-local branch --prune-merged trunk &&
> +
> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged warns instead of erroring on un-integrated commits' '
> + test_when_finished "rm -rf pm-unmerged" &&
> + git clone pm-upstream pm-unmerged &&
> + git -C pm-unmerged remote add fork ../pm-fork &&
> + test_config -C pm-unmerged remote.pushDefault fork &&
> + test_config -C pm-unmerged push.default current &&
> + git -C pm-unmerged checkout -b wip origin/wip &&
> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
> + test_commit -C pm-unmerged local-only &&
> + git -C pm-unmerged checkout - &&
> +
> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
> + test_grep "not fully merged" err &&
> + test_grep ! "If you are sure you want to delete it" err &&
> + git -C pm-unmerged rev-parse --verify refs/heads/wip
> +'
> +
> +test_expect_success '--prune-merged is silent about not-merged-to-HEAD' '
> + test_when_finished "rm -rf pm-nohead" &&
> + git clone pm-upstream pm-nohead &&
> + git -C pm-nohead remote add fork ../pm-fork &&
> + test_config -C pm-nohead remote.pushDefault fork &&
> + test_config -C pm-nohead push.default current &&
> + git -C pm-nohead branch topic one-commit &&
> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
> +
> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
> +
> + test_grep ! "not yet merged to HEAD" err &&
> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged skips branches whose upstream is gone' '
> + test_when_finished "rm -rf pm-upstream-gone" &&
> + git clone pm-upstream pm-upstream-gone &&
> + git -C pm-upstream-gone remote add fork ../pm-fork &&
> + test_config -C pm-upstream-gone remote.pushDefault fork &&
> + test_config -C pm-upstream-gone push.default current &&
> + git -C pm-upstream-gone branch one one-commit &&
> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
> +
> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged never deletes the checked-out branch' '
> + test_when_finished "rm -rf pm-head" &&
> + git clone pm-upstream pm-head &&
> + git -C pm-head remote add fork ../pm-fork &&
> + test_config -C pm-head remote.pushDefault fork &&
> + test_config -C pm-head push.default current &&
> + git -C pm-head checkout -b one one-commit &&
> + git -C pm-head branch --set-upstream-to=origin/next one &&
> +
> + git -C pm-head branch --prune-merged "origin/*" &&
> +
> + git -C pm-head rev-parse --verify refs/heads/one
> +'
> +
> +test_expect_success '--prune-merged spares branches that push back to their upstream' '
> + test_when_finished "rm -rf pm-push-eq" &&
> + git clone pm-upstream pm-push-eq &&
> + git -C pm-push-eq checkout --detach &&
> +
> + git -C pm-push-eq branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-eq rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged spares a per-branch pushRemote==upstream remote' '
> + test_when_finished "rm -rf pm-push-branch" &&
> + git clone pm-upstream pm-push-branch &&
> + git -C pm-push-branch remote add fork ../pm-fork &&
> + test_config -C pm-push-branch remote.pushDefault fork &&
> + test_config -C pm-push-branch push.default current &&
> + test_config -C pm-push-branch branch.main.pushRemote origin &&
> + git -C pm-push-branch checkout --detach &&
> +
> + git -C pm-push-branch branch --prune-merged "origin/*" &&
> +
> + git -C pm-push-branch rev-parse --verify refs/heads/main
> +'
> +
> +test_expect_success '--prune-merged prunes when @{push} differs from @{upstream}' '
> + test_when_finished "rm -rf pm-push-diff" &&
> + git clone pm-upstream pm-push-diff &&
> + git -C pm-push-diff remote add fork ../pm-fork &&
> + test_config -C pm-push-diff remote.pushDefault fork &&
> + test_config -C pm-push-diff push.default current &&
> + git -C pm-push-diff branch topic one-commit &&
> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
> + git -C pm-push-diff checkout --detach &&
> +
> + git -C pm-push-diff branch --prune-merged "origin/*" &&
> +
> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
> +'
> +
> +test_expect_success '--prune-merged requires a value' '
> + test_must_fail git -C forked branch --prune-merged 2>err &&
> + test_grep "requires a value" err
> +'
> +
> +test_expect_success '--prune-merged rejects positional arguments' '
> + test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
> + test_grep "does not take positional arguments" err
> +'
> +
> test_doneThere was a problem hiding this comment.
Phillip Wood wrote on the Git mailing list (how to reply to this email):
On 05/06/2026 14:50, Phillip Wood wrote:
> > I wonder about the name - the other options that delete branches are > called "delete", not "prune". Also "--prune-merged" does not delete the > branches listed by "--merged" so maybe "--delete-forked" would be better?
"delete-forked" doesn't capture the fact the branch has been merged though - I wonder if anyone has a better idea
Thanks
Phillip
> I've not commented in detail on the code as it will need to change a bit > once we match on full refnames and do the filtering in > apply_ref_filter() but I think the basics are sound.
> > I'll stop here - I did quickly scan the next two patches and they both > looked like sensible ideas.
> > Thanks
> > Phillip
> >> deletes the local branches that "--forked <branch>" would list,
>> restricted to 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.
>>
>> Reachability is read from local refs; nothing is fetched. Users
>> who want fresh upstream refs run "git fetch" first.
>>
>> Three classes of branches are spared:
>>
>> * any branch checked out in any worktree;
>> * any branch whose upstream no longer resolves locally (its
>> disappearance is not, on its own, evidence of integration);
>> * any branch whose push destination equals its upstream
>> (<branch>@{push} == <branch>@{upstream}). Such a branch
>> cannot be distinguished from a freshly pulled trunk that
>> just looks "fully merged", e.g. local "main" tracking and
>> pushing to "origin/main" right after a pull. Only branches
>> that push somewhere other than their upstream (typically
>> topics in a fork-based workflow) are treated as candidates.
>>
>> Deletion goes through the existing delete_branches() in warn-only
>> mode and with the HEAD-fallback disabled: a branch that is not
>> yet fully merged to its upstream is reported as a one-line warning
>> and skipped, so a single un-mergeable topic does not abort the
>> whole sweep. We only act on upstream-merged status.
>>
>> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>> ---
>> Documentation/git-branch.adoc | 23 +++++
>> builtin/branch.c | 117 +++++++++++++++++++--
>> t/t3200-branch.sh | 188 ++++++++++++++++++++++++++++++++++
>> 3 files changed, 318 insertions(+), 10 deletions(-)
>>
>> diff --git a/Documentation/git-branch.adoc b/Documentation/git- >> branch.adoc
>> index 8002d7f38c..f7942fcd7d 100644
>> --- a/Documentation/git-branch.adoc
>> +++ b/Documentation/git-branch.adoc
>> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
>> git branch (-c|-C) [<old-branch>] <new-branch>
>> git branch (-d|-D) [-r] <branch-name>...
>> git branch --edit-description [<branch-name>]
>> +git branch (--prune-merged <branch>)...
>> DESCRIPTION
>> -----------
>> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
>> `master`) or a shell-style glob (e.g. `'origin/*'`). The
>> option can be repeated to widen the filter.
>> +`--prune-merged <branch>`::
>> + Delete the local branches that `--forked` would list for the
>> + same _<branch>_, 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. May be given more than once to
>> + union the matches; positional arguments are not accepted.
>> ++
>> +Reachability is checked against whatever the upstream refs say
>> +locally; nothing is fetched. Run `git fetch` first if you want
>> +the upstream refs refreshed.
>> ++
>> +A branch is left alone if any of the following holds:
>> +its upstream no longer resolves locally; it is checked out in any
>> +worktree; or its push destination (`<branch>@{push}`) equals its
>> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
>> +from a freshly pulled trunk that just looks "fully merged".
>> ++
>> +Branches refused by the "fully merged" safety check are listed as
>> +warnings and skipped; pass them to `git branch -D` explicitly if
>> +you want them gone.
>> +
>> `-v`::
>> `-vv`::
>> `--verbose`::
>> diff --git a/builtin/branch.c b/builtin/branch.c
>> index 09afdd9257..736480b002 100644
>> --- a/builtin/branch.c
>> +++ b/builtin/branch.c
>> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
>> N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
>> N_("git branch [<options>] [-r | -a] [--points-at]"),
>> N_("git branch [<options>] [-r | -a] [--format]"),
>> + N_("git branch [<options>] (--prune-merged <branch>)..."),
>> NULL
>> };
>> @@ -782,17 +783,13 @@ static int upstream_matches(const char >> *short_upstream,
>> return 0;
>> }
>> -static int branch_upstream_matches(const char *full_refname,
>> +static int branch_upstream_matches(const char *short_branch_name,
>> const struct upstream_pattern *patterns,
>> size_t nr_patterns)
>> {
>> - const char *short_name;
>> - struct branch *branch;
>> + struct branch *branch = branch_get(short_branch_name);
>> const char *upstream;
>> - if (!skip_prefix(full_refname, "refs/heads/", &short_name))
>> - return 0;
>> - branch = branch_get(short_name);
>> if (!branch)
>> return 0;
>> upstream = branch_get_upstream(branch, NULL);
>> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct >> ref_array *array,
>> for (i = 0; i < array->nr; i++) {
>> struct ref_array_item *item = array->items[i];
>> - if (branch_upstream_matches(item->refname,
>> - patterns, nr_patterns))
>> + const char *short_name;
>> + if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
>> + branch_upstream_matches(short_name, patterns, nr_patterns))
>> array->items[kept++] = item;
>> else
>> free_ref_array_item(item);
>> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct >> ref_array *array,
>> upstream_pattern_list_clear(patterns, nr_patterns);
>> }
>> +struct forked_cb {
>> + const struct upstream_pattern *patterns;
>> + size_t nr_patterns;
>> + struct string_list *out;
>> +};
>> +
>> +static int collect_forked_branch(const struct reference *ref, void >> *cb_data)
>> +{
>> + struct forked_cb *cb = cb_data;
>> +
>> + if (ref->flags & REF_ISSYMREF)
>> + return 0;
>> + if (branch_upstream_matches(ref->name, cb->patterns, cb- >> >nr_patterns))
>> + string_list_append(cb->out, ref->name);
>> + return 0;
>> +}
>> +
>> +static void collect_forked_set(const struct string_list *upstreams,
>> + struct string_list *out)
>> +{
>> + struct upstream_pattern *patterns = NULL;
>> + size_t nr_patterns = 0;
>> + struct forked_cb cb;
>> +
>> + parse_forked_args(upstreams, &patterns, &nr_patterns);
>> + cb.patterns = patterns;
>> + cb.nr_patterns = nr_patterns;
>> + cb.out = out;
>> +
>> + refs_for_each_branch_ref(get_main_ref_store(the_repository),
>> + collect_forked_branch, &cb);
>> +
>> + string_list_sort(out);
>> +
>> + upstream_pattern_list_clear(patterns, nr_patterns);
>> +}
>> +
>> +static int prune_merged_branches(const struct string_list *upstreams,
>> + int quiet)
>> +{
>> + struct ref_store *refs = get_main_ref_store(the_repository);
>> + struct string_list candidates = STRING_LIST_INIT_DUP;
>> + struct strvec deletable = STRVEC_INIT;
>> + struct string_list_item *item;
>> + int ret = 0;
>> +
>> + if (!upstreams->nr)
>> + die(_("--prune-merged requires at least one <branch>"));
>> +
>> + collect_forked_set(upstreams, &candidates);
>> +
>> + for_each_string_list_item(item, &candidates) {
>> + const char *short_name = item->string;
>> + struct branch *branch = branch_get(short_name);
>> + const char *upstream, *push;
>> + struct strbuf full = STRBUF_INIT;
>> + int skip;
>> +
>> + strbuf_addf(&full, "refs/heads/%s", short_name);
>> + skip = !!branch_checked_out(full.buf);
>> + strbuf_release(&full);
>> + if (skip)
>> + continue;
>> +
>> + upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
>> + if (!upstream || !refs_ref_exists(refs, upstream))
>> + continue;
>> + push = branch ? branch_get_push(branch, NULL) : NULL;
>> + if (!push || !strcmp(push, upstream))
>> + continue;
>> +
>> + strvec_push(&deletable, short_name);
>> + }
>> +
>> + if (deletable.nr)
>> + ret = delete_branches(deletable.nr, deletable.v,
>> + 0, /* force */
>> + FILTER_REFS_BRANCHES,
>> + quiet,
>> + 1, /* warn_only */
>> + 1, /* no_head_fallback */
>> + 0 /* dry_run */);
>> +
>> + strvec_clear(&deletable);
>> + string_list_clear(&candidates, 0);
>> + return ret;
>> +}
>> +
>> static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
>> static int edit_branch_description(const char *branch_name)
>> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
>> int delete = 0, rename = 0, copy = 0, list = 0,
>> unset_upstream = 0, show_current = 0, edit_description = 0;
>> struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
>> + struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
>> const char *new_upstream = NULL;
>> int noncreate_actions = 0;
>> /* possible options */
>> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
>> N_("edit the description for the branch")),
>> OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
>> N_("list local branches whose upstream matches <branch> >> (repeatable)")),
>> + OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, >> N_("branch"),
>> + N_("delete local branches whose upstream matches <branch> >> and is merged (repeatable)")),
>> 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")),
>> @@ -965,7 +1054,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 && ! >> prune_merged_upstreams.nr &&
>> + argc == 0)
>> list = 1;
>> if (filter.with_commit || filter.no_commit ||
>> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
>> noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
>> !!show_current + !!list + !!edit_description +
>> - !!unset_upstream;
>> + !!unset_upstream + !!prune_merged_upstreams.nr;
>> if (noncreate_actions > 1)
>> usage_with_options(builtin_branch_usage, options);
>> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
>> ret = delete_branches(argc, argv, delete > 1, filter.kind,
>> quiet, 0, 0, 0);
>> goto out;
>> + } else if (prune_merged_upstreams.nr) {
>> + if (argc)
>> + die(_("--prune-merged does not take positional arguments; "
>> + "repeat --prune-merged for each <branch>"));
>> + ret = prune_merged_branches(&prune_merged_upstreams, quiet);
>> + goto out;
>> } else if (show_current) {
>> print_current_branch_name();
>> ret = 0;
>> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
>> out:
>> string_list_clear(&sorting_options, 0);
>> string_list_clear(&forked_upstreams, 0);
>> + string_list_clear(&prune_merged_upstreams, 0);
>> return ret;
>> }
>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
>> index 4e7deddc04..beb86987ad 100755
>> --- a/t/t3200-branch.sh
>> +++ b/t/t3200-branch.sh
>> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
>> test_grep "requires a value" err
>> '
>> +test_expect_success '--prune-merged: setup' '
>> + test_create_repo pm-upstream &&
>> + test_commit -C pm-upstream base &&
>> + git -C pm-upstream checkout -b next &&
>> + test_commit -C pm-upstream one-commit &&
>> + test_commit -C pm-upstream two-commit &&
>> + git -C pm-upstream branch one HEAD~ &&
>> + git -C pm-upstream branch two HEAD &&
>> + git -C pm-upstream branch wip main &&
>> + git -C pm-upstream checkout main &&
>> + test_create_repo pm-fork
>> +'
>> +
>> +test_expect_success '--prune-merged deletes branches integrated into >> upstream' '
>> + test_when_finished "rm -rf pm-merged" &&
>> + git clone pm-upstream pm-merged &&
>> + git -C pm-merged remote add fork ../pm-fork &&
>> + test_config -C pm-merged remote.pushDefault fork &&
>> + test_config -C pm-merged push.default current &&
>> + git -C pm-merged branch one one-commit &&
>> + git -C pm-merged branch --set-upstream-to=origin/next one &&
>> + git -C pm-merged branch two two-commit &&
>> + git -C pm-merged branch --set-upstream-to=origin/next two &&
>> +
>> + git -C pm-merged branch --prune-merged "origin/*" &&
>> +
>> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
>> + test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a literal upstream' '
>> + test_when_finished "rm -rf pm-literal" &&
>> + git clone pm-upstream pm-literal &&
>> + git -C pm-literal remote add fork ../pm-fork &&
>> + test_config -C pm-literal remote.pushDefault fork &&
>> + test_config -C pm-literal push.default current &&
>> + git -C pm-literal branch one one-commit &&
>> + git -C pm-literal branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-literal branch --prune-merged origin/next &&
>> +
>> + test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged unions multiple <branch> >> arguments' '
>> + test_when_finished "rm -rf pm-union" &&
>> + git clone pm-upstream pm-union &&
>> + git -C pm-union remote add fork ../pm-fork &&
>> + test_config -C pm-union remote.pushDefault fork &&
>> + test_config -C pm-union push.default current &&
>> + git -C pm-union branch one one-commit &&
>> + git -C pm-union branch --set-upstream-to=origin/next one &&
>> + git -C pm-union branch two base &&
>> + git -C pm-union branch --set-upstream-to=origin/main two &&
>> + git -C pm-union checkout --detach &&
>> +
>> + git -C pm-union branch --prune-merged origin/next --prune-merged >> origin/main &&
>> +
>> + test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
>> + test_must_fail git -C pm-union rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a local upstream' '
>> + test_when_finished "rm -rf pm-local" &&
>> + git clone pm-upstream pm-local &&
>> + git -C pm-local remote add fork ../pm-fork &&
>> + test_config -C pm-local remote.pushDefault fork &&
>> + test_config -C pm-local push.default current &&
>> + git -C pm-local checkout -b trunk &&
>> + git -C pm-local branch one one-commit &&
>> + git -C pm-local branch --set-upstream-to=trunk one &&
>> + git -C pm-local merge --ff-only one-commit &&
>> +
>> + git -C pm-local branch --prune-merged trunk &&
>> +
>> + test_must_fail git -C pm-local rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged warns instead of erroring on un- >> integrated commits' '
>> + test_when_finished "rm -rf pm-unmerged" &&
>> + git clone pm-upstream pm-unmerged &&
>> + git -C pm-unmerged remote add fork ../pm-fork &&
>> + test_config -C pm-unmerged remote.pushDefault fork &&
>> + test_config -C pm-unmerged push.default current &&
>> + git -C pm-unmerged checkout -b wip origin/wip &&
>> + git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
>> + test_commit -C pm-unmerged local-only &&
>> + git -C pm-unmerged checkout - &&
>> +
>> + git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
>> + test_grep "not fully merged" err &&
>> + test_grep ! "If you are sure you want to delete it" err &&
>> + git -C pm-unmerged rev-parse --verify refs/heads/wip
>> +'
>> +
>> +test_expect_success '--prune-merged is silent about not-merged-to- >> HEAD' '
>> + test_when_finished "rm -rf pm-nohead" &&
>> + git clone pm-upstream pm-nohead &&
>> + git -C pm-nohead remote add fork ../pm-fork &&
>> + test_config -C pm-nohead remote.pushDefault fork &&
>> + test_config -C pm-nohead push.default current &&
>> + git -C pm-nohead branch topic one-commit &&
>> + git -C pm-nohead branch --set-upstream-to=origin/next topic &&
>> +
>> + git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
>> +
>> + test_grep ! "not yet merged to HEAD" err &&
>> + test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
>> +'
>> +
>> +test_expect_success '--prune-merged skips branches whose upstream is >> gone' '
>> + test_when_finished "rm -rf pm-upstream-gone" &&
>> + git clone pm-upstream pm-upstream-gone &&
>> + git -C pm-upstream-gone remote add fork ../pm-fork &&
>> + test_config -C pm-upstream-gone remote.pushDefault fork &&
>> + test_config -C pm-upstream-gone push.default current &&
>> + git -C pm-upstream-gone branch one one-commit &&
>> + git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
>> + git -C pm-upstream-gone branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-upstream-gone rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged never deletes the checked-out >> branch' '
>> + test_when_finished "rm -rf pm-head" &&
>> + git clone pm-upstream pm-head &&
>> + git -C pm-head remote add fork ../pm-fork &&
>> + test_config -C pm-head remote.pushDefault fork &&
>> + test_config -C pm-head push.default current &&
>> + git -C pm-head checkout -b one one-commit &&
>> + git -C pm-head branch --set-upstream-to=origin/next one &&
>> +
>> + git -C pm-head branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-head rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged spares branches that push back to >> their upstream' '
>> + test_when_finished "rm -rf pm-push-eq" &&
>> + git clone pm-upstream pm-push-eq &&
>> + git -C pm-push-eq checkout --detach &&
>> +
>> + git -C pm-push-eq branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-push-eq rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged spares a per-branch >> pushRemote==upstream remote' '
>> + test_when_finished "rm -rf pm-push-branch" &&
>> + git clone pm-upstream pm-push-branch &&
>> + git -C pm-push-branch remote add fork ../pm-fork &&
>> + test_config -C pm-push-branch remote.pushDefault fork &&
>> + test_config -C pm-push-branch push.default current &&
>> + test_config -C pm-push-branch branch.main.pushRemote origin &&
>> + git -C pm-push-branch checkout --detach &&
>> +
>> + git -C pm-push-branch branch --prune-merged "origin/*" &&
>> +
>> + git -C pm-push-branch rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged prunes when @{push} differs from >> @{upstream}' '
>> + test_when_finished "rm -rf pm-push-diff" &&
>> + git clone pm-upstream pm-push-diff &&
>> + git -C pm-push-diff remote add fork ../pm-fork &&
>> + test_config -C pm-push-diff remote.pushDefault fork &&
>> + test_config -C pm-push-diff push.default current &&
>> + git -C pm-push-diff branch topic one-commit &&
>> + git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
>> + git -C pm-push-diff checkout --detach &&
>> +
>> + git -C pm-push-diff branch --prune-merged "origin/*" &&
>> +
>> + test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/ >> topic
>> +'
>> +
>> +test_expect_success '--prune-merged requires a value' '
>> + test_must_fail git -C forked branch --prune-merged 2>err &&
>> + test_grep "requires a value" err
>> +'
>> +
>> +test_expect_success '--prune-merged rejects positional arguments' '
>> + test_must_fail git -C forked branch --prune-merged origin/one >> other/foreign 2>err &&
>> + test_grep "does not take positional arguments" err
>> +'
>> +
>> test_done
> af17d65 to
eb7268e
Compare
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") 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 --prune-merged, which deletes the
listed branches once they have landed on their upstream.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Add a warn-only mode to delete_branches() and check_branch_commit() so a bulk caller can report branches that are not fully merged as a short warning and carry on, rather than erroring with the longer "use 'git branch -D'" advice that the plain "git branch -d" path emits. Existing callers are unaffected. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
1f6240e to
b937378
Compare
Teach delete_branches() two new modes for the upcoming --prune-merged: one that asks only whether a branch is merged into its upstream, without falling back to HEAD when there is no upstream, and one that rehearses the deletions without removing any ref. Existing callers keep their current behavior. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
git branch --prune-merged <branch>...
deletes the local branches that "--forked <branch>" 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.
Reachability is read from local refs; nothing is fetched. Run
"git fetch" first if you want fresh upstream refs.
Three kinds of branches are spared:
* any branch checked out in any worktree;
* any branch whose upstream no longer resolves locally, since a
missing upstream is not by itself a sign of integration;
* any branch whose push destination equals its upstream
(<branch>@{push} is the same as <branch>@{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 left
alone. Only branches that push somewhere other than their
upstream, typically topics in a fork workflow, are candidates.
Branches that are not yet merged into their upstream are reported
as a short warning and skipped, so one unmerged topic does not
abort the whole sweep.
Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
Setting branch.<name>.pruneMerged=false exempts that branch from "git branch --prune-merged", which is useful for a topic you want to keep developing after an early round of it has been merged upstream. Unless --quiet is given, each skip is reported so the user knows why their topic was kept. Explicit deletion with "git branch -d" still uses the normal merge check and ignores this setting. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
With --dry-run, --prune-merged prints the local branches it would delete, one "Would delete branch <name>" line each, and exits without touching any ref. The same filtering applies, so the output is exactly the set that the real run would delete. --dry-run is only meaningful together with --prune-merged and is rejected otherwise. Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
b937378 to
511de47
Compare
|
/submit |
|
Submitted as pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
--forkedinto a real ref-filter applied inapply_ref_filter()instead of a post-pass, so non-matching branches are never allocated.--forkedpatterns on full refnames (only globs use the abbreviated upstream), and dropped the old helper machinery, forward declaration, and string_list in favor of a strvec.delete_branches()/check_branch_commit()with a singleunsigned int flags.--prune-mergednow collects candidates viafilter_refs()rather than its own branch walk.--prune-mergednow takes its<branch>patterns as positional arguments (e.g.git branch --prune-merged origin/main 'feature*') instead of repeating the option.cc: "Kristoffer Haugsbakk" kristofferhaugsbakk@fastmail.com
cc: Johannes Sixt j6t@kdbg.org
cc: Phillip Wood phillip.wood123@gmail.com