Git development
 help / color / mirror / Atom feed
From: "Harald Nordgren via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com>,
	Johannes Sixt <j6t@kdbg.org>,
	Phillip Wood <phillip.wood123@gmail.com>,
	Harald Nordgren <haraldnordgren@gmail.com>
Subject: [PATCH v11 0/6] branch: prune-merged
Date: Fri, 22 May 2026 11:31:32 +0000	[thread overview]
Message-ID: <pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2285.v10.git.git.1779403204.gitgitgadget@gmail.com>

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
      +'
      +

-- 
gitgitgadget

  parent reply	other threads:[~2026-05-22 11:31 UTC|newest]

Thread overview: 102+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-01 21:35 [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren via GitGitGadget
2026-05-03 22:39 ` Junio C Hamano
2026-05-04 18:28   ` [PATCH] checkout: add --autostash option for branch switching Harald Nordgren
2026-05-10  1:01     ` Junio C Hamano
2026-05-05  7:14   ` [PATCH] fetch: add fetch.pruneLocalBranches config Johannes Sixt
2026-05-04 18:27 ` [PATCH v2 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-04 18:27   ` [PATCH v2 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-04 23:25     ` Kristoffer Haugsbakk
2026-05-04 18:27   ` [PATCH v2 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-04 18:27   ` [PATCH v2 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-04 18:27   ` [PATCH v2 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-04 18:27   ` [PATCH v2 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-04 18:27   ` [PATCH v2 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05  7:22   ` [PATCH v3 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05  7:22     ` [PATCH v3 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-05 19:23     ` [PATCH v4 0/6] fetch: add fetch.pruneBranches config Harald Nordgren via GitGitGadget
2026-05-05 19:23       ` [PATCH v4 1/6] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23       ` [PATCH v4 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-05 19:23       ` [PATCH v4 3/6] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-05 19:23       ` [PATCH v4 4/6] fetch: add --prune-merged Harald Nordgren via GitGitGadget
2026-05-05 20:48         ` Johannes Sixt
2026-05-05 22:07           ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11  2:59             ` Junio C Hamano
2026-05-11  6:56               ` Harald Nordgren
2026-05-05 19:23       ` [PATCH v4 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-05 19:23       ` [PATCH v4 6/6] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-07 20:14       ` [PATCH v4 0/6] fetch: add fetch.pruneBranches config Harald Nordgren
2026-05-11  6:58       ` [PATCH v5 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11  6:58         ` [PATCH v5 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11  6:58         ` [PATCH v5 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11  8:18           ` Junio C Hamano
2026-05-11  8:44             ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-11  6:58         ` [PATCH v5 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11  6:58         ` [PATCH v5 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11  6:58         ` [PATCH v5 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11  9:44         ` [PATCH v6 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-11  9:44           ` [PATCH v6 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-11  9:44           ` [PATCH v6 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-11  9:44           ` [PATCH v6 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-11  9:44           ` [PATCH v6 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-11  9:44           ` [PATCH v6 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-11 23:20           ` [PATCH v6 0/5] branch: prune-merged Junio C Hamano
2026-05-12  7:35             ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12  8:23           ` [PATCH v7 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12  8:23             ` [PATCH v7 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12  8:23             ` [PATCH v7 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12  8:23             ` [PATCH v7 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 13:53               ` Junio C Hamano
2026-05-12 17:00                 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-12  8:23             ` [PATCH v7 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12  8:23             ` [PATCH v7 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-12 17:07             ` [PATCH v8 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-12 17:07               ` [PATCH v8 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07               ` [PATCH v8 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-12 17:07               ` [PATCH v8 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-12 17:07               ` [PATCH v8 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-12 17:07               ` [PATCH v8 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-13 13:46               ` [PATCH v8 0/5] branch: prune-merged Junio C Hamano
2026-05-13 18:57                 ` [PATCH] fetch: add fetch.pruneLocalBranches config Harald Nordgren
2026-05-13 19:34               ` [PATCH v9 0/5] branch: prune-merged Harald Nordgren via GitGitGadget
2026-05-13 19:34                 ` [PATCH v9 1/5] branch: add --forked <remote> Harald Nordgren via GitGitGadget
2026-05-13 19:34                 ` [PATCH v9 2/5] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-13 19:34                 ` [PATCH v9 3/5] branch: add --prune-merged <remote> Harald Nordgren via GitGitGadget
2026-05-18 15:27                   ` Phillip Wood
2026-05-21  9:46                     ` Phillip Wood
2026-05-21 19:16                       ` Harald Nordgren
2026-05-22  9:47                         ` Phillip Wood
2026-05-22 10:51                           ` Harald Nordgren
2026-05-21 12:37                     ` Harald Nordgren
2026-05-21 13:29                     ` Junio C Hamano
2026-05-13 19:34                 ` [PATCH v9 4/5] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-13 19:34                 ` [PATCH v9 5/5] branch: add --all-remotes flag Harald Nordgren via GitGitGadget
2026-05-18 15:27                   ` Phillip Wood
2026-05-18  8:14                 ` [PATCH v9 0/5] branch: prune-merged Harald Nordgren
2026-05-21 22:40                 ` [PATCH v10 0/4] " Harald Nordgren via GitGitGadget
2026-05-21 22:40                   ` [PATCH v10 1/4] branch: add --forked <branch> Harald Nordgren via GitGitGadget
2026-05-22  1:52                     ` Junio C Hamano
2026-05-22  6:18                     ` Johannes Sixt
2026-05-22  6:36                       ` Junio C Hamano
2026-05-22 10:49                         ` Harald Nordgren
2026-05-22 11:25                           ` Johannes Sixt
2026-05-21 22:40                   ` [PATCH v10 2/4] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
2026-05-22  1:17                     ` Junio C Hamano
2026-05-22  2:51                     ` Junio C Hamano
2026-05-22  2:53                       ` Junio C Hamano
2026-05-22  7:59                         ` Harald Nordgren
2026-05-22 11:58                           ` Junio C Hamano
2026-05-22  2:52                     ` Junio C Hamano
2026-05-21 22:40                   ` [PATCH v10 3/4] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-21 22:40                   ` [PATCH v10 4/4] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget
2026-05-22 11:31                   ` Harald Nordgren via GitGitGadget [this message]
2026-05-22 11:31                     ` [PATCH v11 1/6] branch: add --forked <branch> Harald Nordgren via GitGitGadget
2026-05-22 11:31                     ` [PATCH v11 2/6] branch: let delete_branches warn instead of error on bulk refusal Harald Nordgren via GitGitGadget
2026-05-22 11:31                     ` [PATCH v11 3/6] branch: prepare delete_branches for a bulk caller Harald Nordgren via GitGitGadget
2026-05-22 11:31                     ` [PATCH v11 4/6] branch: add --prune-merged <branch> Harald Nordgren via GitGitGadget
2026-05-22 11:31                     ` [PATCH v11 5/6] branch: add branch.<name>.pruneMerged opt-out Harald Nordgren via GitGitGadget
2026-05-22 11:31                     ` [PATCH v11 6/6] branch: add --dry-run for --prune-merged Harald Nordgren via GitGitGadget

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=pull.2285.v11.git.git.1779449498.gitgitgadget@gmail.com \
    --to=gitgitgadget@gmail.com \
    --cc=git@vger.kernel.org \
    --cc=haraldnordgren@gmail.com \
    --cc=j6t@kdbg.org \
    --cc=kristofferhaugsbakk@fastmail.com \
    --cc=phillip.wood123@gmail.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox